Metered access: spec

Table of Contents

1. Purpose

Metered access counts page views per UTC day and classifies the reader as :open, :metered, :gated or :subscribed. Every core function takes the clock as an explicit argument; the core never reads host time and never writes storage. Namespace: wal-sh.adtech.metered-access.core (host-neutral .cljc).

ORACLE: src/wal_sh/adtech/metered_access/core.cljc (pure), browser.cljs (storage + banner/wall).

2. Requirements

Types:

clock
long ms epoch | 'YYYY-MM-DD' string. ALWAYS an explicit argument. No core fn calls Date.now / System.currentTimeMillis.
state
{:bucket 'YYYY-MM-DD', :views Long≥0, :subscriber Bool}.
config
{:limit Long≥0, :tz #{:utc}}. :tz is :utc only; the JS module used implicit local TZ; we pin UTC for determinism.
bucket
ISO YYYY-MM-DD UTC date string.
result
{:state KW, :views, :remaining, :bucket, :next-state} where :state ∈ #{:open :metered :gated :subscribed}.

Semantic decisions worth flagging:

  1. UTC-only buckets. Local-TZ rollover is intentionally out of scope; it eliminates ~6 hours of DST/skew edge cases. PLANS.org's :tz option is reserved for future expansion behind validation.
  2. over-limit? is STRICTLY greater-than. At views = limit= the classification is :metered (banner) not :gated (wall). Matches PLANS spec ∀ v ≤ limit: (over-limit?) = false and the JS state machine (views = METER_LIMIT → METERED=, not GATED).
  3. maybe-reset is forward-only. Clock skew backwards (user sets system clock back) does NOT decrement and does NOT roll over.
  4. increment doesn't dedupe timestamps. Idempotency is the caller's job. Two increment calls with the same clock count as two views; the negative property test pins this.
  5. evaluate returns :next-state alongside the classification. The caller persists it. Core never writes; browser.cljs owns storage.
  6. Subscriber flag dominates. over-limit? returns false for any subscriber regardless of limit (even :limit 0).

3. Contract signature

Pure (core.cljc):

Fn Sig Pure
default-config Config yes
default-limit 3 yes
tiers / public-tiers [Tier ...] yes
bucket-of clock → bucket? yes
valid-config? config → Bool yes
normalize-config config? → Config (throws ex-info on bad) yes
empty-state clock → State yes
maybe-reset state × clock → state yes
subscriber? state → Bool yes
subscribe state → state (sets flag) yes
unsubscribe state → state yes
over-limit? config × state × clock → Bool yes
remaining config × state × clock → Long or :infinity yes
increment config × state × clock → state' yes
evaluate config × state × clock → result yes
tier-by-id String → Tier? yes
subscribe-result String → {:status :tier :price :message} yes

4. Negative-state catalog (property tests)

Each row is a hostile input the module must NOT misbehave on. Property tests in test/wal_sh/adtech/metered_access/core_prop_test.cljc pin the contract.

# Failure mode Defense defspec
1 clock = nil bucket-of nil → nil; no exception clock-nil-rejected
2 clock = negative ms epoch bucket-of (- n) → nil; rejects JS Date(-1) quirk negative-ms-rejected
3 clock = garbage string bucket-of "yesterday" → nil garbage-string-rejected
4 :limit < 0 valid-config? → false; normalize-config throws negative-limit-rejected
5 :limit is fractional rejected via integer? (CLJS: requires non-zero frac) non-integer-limit-rejected
6 :tz:utc rejected at validation bad-tz-rejected
7 :limit 0 always over-limit; :gated from view 1 limit-zero-always-gated
8 Clock moves BACKWARDS across midnight counter unchanged; bucket NOT rolled over; no underflow clock-skew-backwards-never-decrements
9 Repeated increment at same ms each call counts (no implicit dedupe) duplicate-timestamps-count-each
10 Subscriber flag set with view = 10⁶ over-limit? → false regardless of count or limit subscriber-bypass-dominates

Browser-only failure modes (covered in browser.cljs, not in property tests):

# Failure mode Defense
A localStorage.getItem throws (private) try/catch :default _; flip :degraded? in memstore; in-memory fallback
B localStorage.setItem throws (quota) same safe-set! wrapper; writes succeed in memstore atom
C Multiple tabs racing Last-write-wins (sessionStorage is per-tab; localStorage subscriber flag is monotone: once true, stays true)
D sessionStorage JSON parse failure Fall back to empty-state (Date.now) rather than crash init

Decisions vs PLANS.org defaults:

  • TZ: Locked to UTC. Local-TZ midnight is the most common source of paywall bugs ("the user crossed midnight in their TZ but not the server's"). UTC is one boundary, globally; documented in decision 1 above.
  • Idempotency boundary: Core counts every call; browser is responsible for deduping double-fires from SPA navigations. Inverting this would force the core to carry an opaque visit-id set.
  • Multi-tab: Eventual consistency. We rejected a BroadcastChannel reconciliation layer because the JS module didn't have one and the paywall is performance art ($0.00 revenue).

5. Cross-references

  • content-gate/spec.org: the sibling paywall module; the gate checks tokens, the meter counts views. Both keep storage in the browser adapter.
  • tracker/spec.org: the passive tracker whose page-view event is the natural increment trigger.
  • ORACLE:
    • src/wal_sh/adtech/metered_access/core.cljc (pure)
    • src/wal_sh/adtech/metered_access/browser.cljs (storage + wall)
    • test/wal_sh/adtech/metered_access/core_prop_test.cljc (properties)

6. Open questions

  • Local-TZ buckets. PLANS.org's :tz option is validated to :utc only; opening it would reintroduce the DST/skew edge cases the UTC pin removes.
  • Multi-tab reconciliation. Eventual consistency is accepted today; a BroadcastChannel layer is the known fix if the paywall ever carries revenue.