Skip to Content
PaywallMonetizationOS (MOS)Website Surface Workflow

Website Surface Workflow (website-surface)

website-surface is the generic, copy-paste-ready Surface Workflow for setting up MonetizationOS (MOS) with Sophi Paywall and Sophi intelligence. MOS itself is an unopinionated developer platform — this design is the Sophi setup, an opinionated reference a client copies into the MOS surface editor and adapts to their own site.

The workflow is authored and configured entirely inside MOS (the surface editor), per client — it is not part of the Cloudflare Worker repository. It runs first, on every request, before any feature/meter/cookie work. It classifies the page (article vs section) and short-circuits section pages, resolves the current page’s feature, evaluates that feature’s Enabled and powered-by-sophi properties, and hands the resulting access decision to child workflow components (article body rendering, analytics data layer, cookie management) via properties.outcome — along with properties.visitor, properties.page, and a diagnostic decision trail in properties.logs.

Prefer changing this workflow (or a workflow component) over modifying the Cloudflare Worker whenever the business requirement can be expressed here instead. See Extension Points below for the pieces of this design meant to be filled in per client.

This is the base reference for adding new decision flows (e.g. dedicated ad-blocked-user handling) on top of the existing paths.

Output Contract

{ "properties": { "outcome": { "wallVisibility": "never|always", "wallType": "none|paywall|regwall", "decisionSource": "sophi|override|error|meter|sophi-script-blocked" }, "visitor": { "visitorType": "anonymous|registered|subscribed", "isLoggedIn": true, "isSophiScriptBlocked": false }, "page": { "matchedFeature": "all-articles", "pageType": "article|section" }, "logs": { "pathname": "...", "candidates": ["..."], "decisionPath": "...", "meter": { "...": "..." } } } }
  • visitor.isSophiScriptBlocked and visitor.isLoggedIn are booleans (true/false).
  • page.pageType is article for content pages, section otherwise — see Extension Points for how this classification is actually determined per client. Section pages short-circuit: the workflow returns never / none / override before any feature, cookie, or meter evaluation, so section pageviews are never metered and always report isSophiScriptBlocked: false.
  • logs carries the full decision trail (which branch fired, meter breakdown, cookie state) so components can log why a decision was made — see Diagnostic Logs.
  • outcome.decisionSource tells downstream consumers (analytics, the gtm-datalayer component) why the wall decision was made:
    • override — a hardcoded feature setting (Enabled = on/off) or regular MOS metering decided it.
    • sophi — the sophi_demeter cookie decided it.
    • meter — no sophi_demeter cookie was present, and the meter can’t yet prove this isn’t the first pageview this period, so the all-articles meter decided it (see No Sophi Cookie below).
    • sophi-script-blocked — no sophi_demeter cookie was present, and the meter proves this browser already consumed a view this period — a reliable ad-blocked signal. visitor.isSophiScriptBlocked is also set to true in this case.
    • error — no features are configured at all (MOS misconfiguration); fails open.

isSophiScriptBlocked scope: ad-blocker detection only runs on article pages — section pages return early and always report isSophiScriptBlocked: false. The detection also assumes the Sophi script loads sitewide on every brand: a missing sophi_demeter cookie is only meaningful as an ad-blocked signal if the script would otherwise have set it. On any brand where the Sophi script is intentionally not injected, cookieless repeat visitors will be incorrectly flagged as ad blocked.

Full Decision Flow

The visitor/page derivation feeds the flow above and is computed up front, before any branching (including the section short-circuit). Both boxes marked below are client-specific — see Extension Points:

Path Catalog

Every terminal outcome the workflow can produce, in the order they’re evaluated (the section short-circuit runs before everything else):

#ConditionwallVisibilitywallTypedecisionSourcelogs.decisionPathNotes
1Section page (pageType ≠ article)nevernoneoverridesection-skipRuns first, before any feature/cookie/meter work — sections never get a wall decision, are never metered, and never report an ad-blocked signal. matchedFeature and visitor are still resolved.
2Article page, no feature resolves, not even all-articlesnevernoneerrormissing-feature-errorMOS config error; fails open so users aren’t blocked by a broken config.
3Matched feature Enabled = onnevernoneoverrideenabled-onFree content.
4Matched feature Enabled = offalwayspaywalloverrideenabled-offHard paywall, never a regwall.
5Enabled = metered, powered-by-sophi = false, meter has remaining viewsnevernoneoverridemetered-regularNative MOS metering on the matched feature. Reuses the probe’s consume() result when the matched feature IS all-articles.
6Enabled = metered, powered-by-sophi = false, meter exhaustedalwayspaywalloverridemetered-regularAlways paywall when gated.
7metered + powered-by-sophi = true, cookie present, outcome.wallVisibility=nevernevernonesophimetered-sophi-cookieSophi says no wall.
8metered + powered-by-sophi = true, cookie present, wall + paywall (or missing wallType)alwayspaywallsophimetered-sophi-cookieSophi says wall; a missing cookie wallType defaults to paywall.
9metered + powered-by-sophi = true, cookie present, wall + regwall, no login_idalwaysregwallsophimetered-sophi-cookieSophi says regwall, user isn’t logged in — honored.
10metered + powered-by-sophi = true, cookie present, wall + regwall, has login_idalwayspaywallsophimetered-sophi-cookie-regwall-override-to-paywallSafety override — a logged-in user should never see a regwall.
11metered + powered-by-sophi = true, no cookie, genuinely first meter hit this period (consumedUnits === consumedUnitsInRequest), meter has remaining viewsnevernonemetermetered-sophi-no-cookie-first-pageviewAmbiguous fallback — could be a real first pageview (see below).
12metered + powered-by-sophi = true, no cookie, genuinely first meter hit this period, meter exhaustedalwayspaywallmetermetered-sophi-no-cookie-first-pageviewAmbiguous fallback; always paywall, never regwall.
13metered + powered-by-sophi = true, no cookie, meter proves prior consumption this period (consumedUnits > consumedUnitsInRequest), meter has remaining viewsnevernonesophi-script-blockedmetered-sophi-no-cookie-sophi-script-blockedReliable ad-blocked signal — isSophiScriptBlocked = true (boolean).
14metered + powered-by-sophi = true, no cookie, meter proves prior consumption this period, meter exhaustedalwayspaywallsophi-script-blockedmetered-sophi-no-cookie-sophi-script-blockedReliable ad-blocked signal — isSophiScriptBlocked = true (boolean). Always paywall, never regwall.

When powered-by-sophi = true but the sophi_demeter cookie is missing from the request, there are two indistinguishable causes at the edge:

  1. First pageview — the Sophi script hasn’t executed yet, so it hasn’t had a chance to set the cookie.
  2. Ad blocked — an ad blocker prevents the Sophi script from ever loading, so the cookie will never appear, on this pageview or any future one.

The workflow can’t tell these apart from a single request alone, so instead of trusting a fixed configuration guess, it falls back to metering. The consume() on the all-articles feature’s Enabled meterable property (always all-articles, regardless of which feature actually matched the current path) happens once, up front, in the universal ad-blocker probe — before the Enabled / powered-by-sophi branching — and every branch that needs a meter result reuses that cached call, so no meter is ever double-consumed within one request. The fallback then lets the remaining-views state decide access. This guarantees ad-blocked users are still metered instead of always failing open, while still allowing genuine first-pageview visitors through as long as they have views remaining.

Side effect: because the probe runs on every cookieless article pageview, the all-articles meter is consumed even when the final decision comes from another branch (Enabled = on/off, or regular metering on a different matched feature). The one dedup case: when regular metering runs and the matched feature is all-articles, the branch reuses the probe’s result instead of consuming twice.

However, the consume() result does let us tell the two cases apart across multiple requests. In short: consumedUnits (cumulative for the period, including this call) compared to consumedUnitsInRequest (just this call) tells us whether this meter had any history before this request:

  • consumedUnits === consumedUnitsInRequest → genuinely the first meter hit this period → still ambiguous → decisionSource = 'meter'.
  • consumedUnits > consumedUnitsInRequest → this browser was already metered earlier this period, yet the cookie still never arrived → a working Sophi script would have set it by now → reliable ad-blocked signal → decisionSource = 'sophi-script-blocked' and visitor.isSophiScriptBlocked = true.

isSophiScriptBlocked

visitor.isSophiScriptBlocked is a boolean. It starts false and flips to true the moment the universal probe proves a repeat visit without a cookie (consumedUnits > consumedUnitsInRequest) — before any Enabled / powered-by-sophi branching. Because the probe runs universally, the flag is a visitor-level signal independent of what decided the wall: it can be true alongside decisionSource = 'override' (the Enabled = on/off and regular-metering paths) or 'sophi-script-blocked'. It is always false:

  • when the sophi_demeter cookie is present (a set cookie already proves the script ran, so the probe never touches the meter in that case),
  • on a genuine first meter hit (decisionSource = 'meter') — that case is still indistinguishable from a real first pageview, so we don’t guess,
  • on section pages (which return early, before the probe), and
  • on the missing-feature error path (the probe can’t consume a meter that doesn’t exist).

Detection currently only runs on article pages — section pages return early and always report false. Limitations: it is period-scoped, it can’t detect anything on the very first pageview, and isFallback meter results are not currently guarded against.

Diagnostic Logs (properties.logs)

Every response — including the early returns — carries a logs object with the full decision trail, so components can log why a decision was made instead of just the final outcome.

KeyWhen presentContents
pathnamealwaysParsed request path.
candidatesalwaysFeature slug candidates tried, most → least specific.
matchedFeaturealwaysWinning feature key.
pageTypealwaysarticle or section.
sophiCookiePresentarticle pagesWhether a sophi_demeter cookie was found.
sophiCookieOutcomearticle pagesParsed { wallVisibility, wallType } from the cookie, or null.
meterwhenever a meter was consumed{ hasAccess, remainingUnits, totalUnits, consumedUnits, consumedUnitsInRequest, isFallback }. On the regular-metering path this reflects the matched feature’s meter; everywhere else it reflects the all-articles probe.
isRepeatVisitWithoutCookiearticle pages without a cookieResult of the consumedUnits > consumedUnitsInRequest check.
enabled / poweredBySophiafter the feature-exists checkResolved property values that drove the branching.
decisionPathalwaysTerminal branch tag — one per row of the Path Catalog.

Extension Points

website-surface is designed to be copied into a client’s MOS surface editor as-is and adapted. The following pieces of logic are intentionally left generic here — each is a placeholder a client fills in for their own site before going live. Treat each as a function comment to be completed later, not a fixed implementation:

1. Page classification (pageType)

  • Purpose: decide whether the current request is an article (content page subject to a wall decision) or a section (index/listing page, always free, never metered).
  • Input: the request pathname today (see the default heuristic below); a client may instead classify off CMS-provided page metadata (e.g. a <meta> tag — see Extension Point 3) once that signal is available.
  • Output: exactly "article" | "section".
  • Default starting point: a simple pathname-suffix check (e.g. paths ending in .html are articles, everything else is a section). This is a reasonable default for many CMSes but is expected to be replaced with the client’s actual content-type signal.

2. Visitor type derivation (deriveVisitorType)

  • Purpose: classify the requesting visitor for analytics (visitor.visitorType) from identity claims.
  • Input: identity.jwtClaims, whose shape varies per client identity provider (e.g. the claim name carrying a login/subscriber id, and however active subscriptions/editions are represented).
  • Output: exactly "anonymous" | "registered" | "subscribed".
  • Default starting point: no recognized login claim → anonymous; login claim plus one or more active subscriptions/editions → subscribed; login claim only → registered.

3. Metadata-tag-driven override

  • Purpose: let a client react to a specific CMS-authored signal without touching the Cloudflare Worker or the core decision flow — e.g. overriding pageType for content types the pathname heuristic gets wrong, or forcing a wall outcome for a specific content flag.
  • Input: resource.meta, a flat { [tagName]: content } map of every <meta name="..."> / <meta property="..."> tag found on the origin page (populated by the Worker’s page-metadata extraction stage before the surface-decisions call).
  • Action: reads one specific tag (name TBD per client) out of resource.meta and applies a client-defined action based on its value — e.g. reclassifying pageType, short-circuiting to a fixed outcome, or annotating logs for downstream analytics.
  • Status: no default behavior — this hook does nothing until a client defines the tag name and the action it should trigger.

Extension Guidance

When adding a new flow on top of this design:

  • Add new terminal states to the Path Catalog table above and to the flowchart.
  • Keep decisionSource values meaningful and stable — the gtm-datalayer component forwards decisionSource verbatim, so values are effectively a public contract.
  • Prefer branching on data already available on resource/features (cookies, feature properties, resource.meta) over new Cloudflare Worker env vars — MOS-side configuration (features, meters, this workflow) is preferred over Worker env vars for business logic. Start with the Extension Points above before considering a Worker change.
  • Tag each new terminal branch with a stable logs.decisionPath value and add it to the Path Catalog — debug console output and support triage depend on it.
  • Always paywall (never regwall) for any new fail-safe/fallback path — regwall is reserved for genuine Sophi-driven decisions on anonymous users.
  • Add a test scenario per new terminal state to this workflow’s spec suite.
Last updated on