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:
clocklong ms epoch | 'YYYY-MM-DD' string. ALWAYS an explicit argument. No core fn callsDate.now/System.currentTimeMillis.state{:bucket 'YYYY-MM-DD', :views Long≥0, :subscriber Bool}.config{:limit Long≥0, :tz #{:utc}}.:tzis:utconly; the JS module used implicit local TZ; we pin UTC for determinism.bucket- ISO
YYYY-MM-DDUTC date string. result{:state KW, :views, :remaining, :bucket, :next-state}where:state ∈ #{:open :metered :gated :subscribed}.
Semantic decisions worth flagging:
- UTC-only buckets. Local-TZ rollover is intentionally out of
scope; it eliminates ~6 hours of DST/skew edge cases. PLANS.org's
:tzoption is reserved for future expansion behind validation. over-limit?is STRICTLY greater-than. Atviews =limit= the classification is:metered(banner) not:gated(wall). Matches PLANS spec∀ v ≤ limit: (over-limit?) = falseand the JS state machine (views =METER_LIMIT → METERED=, notGATED).maybe-resetis forward-only. Clock skew backwards (user sets system clock back) does NOT decrement and does NOT roll over.incrementdoesn't dedupe timestamps. Idempotency is the caller's job. Twoincrementcalls with the sameclockcount as two views; the negative property test pins this.evaluatereturns:next-statealongside the classification. The caller persists it. Core never writes; browser.cljs owns storage.- Subscriber flag dominates.
over-limit?returns false for any subscriber regardless oflimit(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
BroadcastChannelreconciliation 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
incrementtrigger. - 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
:tzoption is validated to:utconly; opening it would reintroduce the DST/skew edge cases the UTC pin removes. - Multi-tab reconciliation. Eventual consistency is accepted today;
a
BroadcastChannellayer is the known fix if the paywall ever carries revenue.