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.isSophiScriptBlockedandvisitor.isLoggedInare booleans (true/false).page.pageTypeisarticlefor content pages,sectionotherwise — see Extension Points for how this classification is actually determined per client. Section pages short-circuit: the workflow returnsnever / none / overridebefore any feature, cookie, or meter evaluation, so section pageviews are never metered and always reportisSophiScriptBlocked: false.logscarries the full decision trail (which branch fired, meter breakdown, cookie state) so components can log why a decision was made — see Diagnostic Logs.outcome.decisionSourcetells downstream consumers (analytics, thegtm-datalayercomponent) why the wall decision was made:override— a hardcoded feature setting (Enabled = on/off) or regular MOS metering decided it.sophi— thesophi_demetercookie decided it.meter— nosophi_demetercookie was present, and the meter can’t yet prove this isn’t the first pageview this period, so theall-articlesmeter decided it (see No Sophi Cookie below).sophi-script-blocked— nosophi_demetercookie was present, and the meter proves this browser already consumed a view this period — a reliable ad-blocked signal.visitor.isSophiScriptBlockedis also set totruein this case.error— no features are configured at all (MOS misconfiguration); fails open.
isSophiScriptBlockedscope: ad-blocker detection only runs on article pages — section pages return early and always reportisSophiScriptBlocked: false. The detection also assumes the Sophi script loads sitewide on every brand: a missingsophi_demetercookie 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):
| # | Condition | wallVisibility | wallType | decisionSource | logs.decisionPath | Notes |
|---|---|---|---|---|---|---|
| 1 | Section page (pageType ≠ article) | never | none | override | section-skip | Runs 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. |
| 2 | Article page, no feature resolves, not even all-articles | never | none | error | missing-feature-error | MOS config error; fails open so users aren’t blocked by a broken config. |
| 3 | Matched feature Enabled = on | never | none | override | enabled-on | Free content. |
| 4 | Matched feature Enabled = off | always | paywall | override | enabled-off | Hard paywall, never a regwall. |
| 5 | Enabled = metered, powered-by-sophi = false, meter has remaining views | never | none | override | metered-regular | Native MOS metering on the matched feature. Reuses the probe’s consume() result when the matched feature IS all-articles. |
| 6 | Enabled = metered, powered-by-sophi = false, meter exhausted | always | paywall | override | metered-regular | Always paywall when gated. |
| 7 | metered + powered-by-sophi = true, cookie present, outcome.wallVisibility=never | never | none | sophi | metered-sophi-cookie | Sophi says no wall. |
| 8 | metered + powered-by-sophi = true, cookie present, wall + paywall (or missing wallType) | always | paywall | sophi | metered-sophi-cookie | Sophi says wall; a missing cookie wallType defaults to paywall. |
| 9 | metered + powered-by-sophi = true, cookie present, wall + regwall, no login_id | always | regwall | sophi | metered-sophi-cookie | Sophi says regwall, user isn’t logged in — honored. |
| 10 | metered + powered-by-sophi = true, cookie present, wall + regwall, has login_id | always | paywall | sophi | metered-sophi-cookie-regwall-override-to-paywall | Safety override — a logged-in user should never see a regwall. |
| 11 | metered + powered-by-sophi = true, no cookie, genuinely first meter hit this period (consumedUnits === consumedUnitsInRequest), meter has remaining views | never | none | meter | metered-sophi-no-cookie-first-pageview | Ambiguous fallback — could be a real first pageview (see below). |
| 12 | metered + powered-by-sophi = true, no cookie, genuinely first meter hit this period, meter exhausted | always | paywall | meter | metered-sophi-no-cookie-first-pageview | Ambiguous fallback; always paywall, never regwall. |
| 13 | metered + powered-by-sophi = true, no cookie, meter proves prior consumption this period (consumedUnits > consumedUnitsInRequest), meter has remaining views | never | none | sophi-script-blocked | metered-sophi-no-cookie-sophi-script-blocked | Reliable ad-blocked signal — isSophiScriptBlocked = true (boolean). |
| 14 | metered + powered-by-sophi = true, no cookie, meter proves prior consumption this period, meter exhausted | always | paywall | sophi-script-blocked | metered-sophi-no-cookie-sophi-script-blocked | Reliable ad-blocked signal — isSophiScriptBlocked = true (boolean). Always paywall, never regwall. |
No Sophi Cookie: First Pageview vs. Ad Blocked
When powered-by-sophi = true but the sophi_demeter cookie is missing from the request, there
are two indistinguishable causes at the edge:
- First pageview — the Sophi script hasn’t executed yet, so it hasn’t had a chance to set the cookie.
- 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-articlesmeter 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 isall-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'andvisitor.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_demetercookie 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
errorpath (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.
| Key | When present | Contents |
|---|---|---|
pathname | always | Parsed request path. |
candidates | always | Feature slug candidates tried, most → least specific. |
matchedFeature | always | Winning feature key. |
pageType | always | article or section. |
sophiCookiePresent | article pages | Whether a sophi_demeter cookie was found. |
sophiCookieOutcome | article pages | Parsed { wallVisibility, wallType } from the cookie, or null. |
meter | whenever 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. |
isRepeatVisitWithoutCookie | article pages without a cookie | Result of the consumedUnits > consumedUnitsInRequest check. |
enabled / poweredBySophi | after the feature-exists check | Resolved property values that drove the branching. |
decisionPath | always | Terminal 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 asection(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
.htmlare 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
pageTypefor 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.metaand applies a client-defined action based on its value — e.g. reclassifyingpageType, short-circuiting to a fixedoutcome, or annotatinglogsfor 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
decisionSourcevalues meaningful and stable — thegtm-datalayercomponent forwardsdecisionSourceverbatim, 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.decisionPathvalue 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.