M MemberIntel KB
Activity Decisions

decision

ADR-0041: Free-tier limits are bounded by member count, NOT by connector platform

ADR-0041 (Accepted (Seth, 2026-07-29). Records a **rejected** approach — the decision is *not* to gate Free on connector platform identity., 2026-07-29): Free-tier limits are bounded by member count, NOT by connector platform.

Status: Accepted (Seth, 2026-07-29). Records a rejected approach — the decision is not to gate Free on connector platform identity.
Date: 2026-07-29
Deciders: Seth (decision owner). ai-engineer cost-discipline review: docs/superpowers/reviews/2026-07-29-ai-engineer-787-free-tier-connector-policy.md (APPROVE-WITH-CHANGES, recommending “Option A”). Reviewer: Omar (on #834).
Related: #787 (the issue this answers), #784 (the real constraint), #852 (the durable fix), #851 (the fairness half), #818 (complementary, on cost grounds), #838, #825/ADR-0040, #750.

This ADR goes one step further than the review’s Option A. Option A was “keep
FREE_ELIGIBLE_PLATFORMS as data plus the completeness test, cut the refusal
machinery.” The decision below drops the constant and the test as well, because
with the answer permanently “yes” the list is KNOWN_PLATFORMS under another name
and the test asserts a tautology (see Consequences). The review’s reasoning for
cutting the machinery is adopted unchanged; only that residue differs.

Context

#787 asked for a cap on how many connector modules a Free site may activate.
Nothing bounded it: src/memberintel/api/sites/integrations.py::normalize_integrations
records whatever the plugin reports, and src/memberintel/entitlement/tiers.py capped
LLM operations and sync cadence but not connector fan-out. It was framed as the limit #750’s cost
blocker turned on — “before we let a customer opt into the full stack on Free.”

The obvious shape was an allow-list naming which platforms Free may activate, with
WishList excluded because it measured 202 of the ~243 queries a /members page
costs across all ten modules — ~83%, from a per-member \WishListMember\User
instantiation (#784).

That shape was implemented in full on PR #834 — policy in
src/memberintel/entitlement/tiers.py, refusal at both connect paths, detection at
sync — and then withdrawn. This ADR records why,
because the reasoning outlived the code.

Decision

Connector platform identity is NOT a tier-gating axis. What bounds a Free site is
its size — members × platforms — and this ADR adopts no bound on it.
No platform
is Pro-only. FREE_ELIGIBLE_PLATFORMS, the PlatformRequiresProError refusal at
both connect paths, and the sync-side detection are all removed; nothing replaces
them here.

To be explicit about the negative half, because this ADR’s whole complaint about the
withdrawn code is that it read like a live control: nothing limits a Free site’s
size today, and nothing in this ADR starts to.
The size-based bound is deferred,
and if it returns its shape is cadence degradation, not a hard member cap — that
was explored and closed as #844 (see follow-ups). Do not read the title as saying a
member limit is in force; it names the axis a future limit belongs on.

Three findings, in increasing order of how much they mattered.

1. The stated cost basis evaporated within a day

#838 merged (55bff60a, 2026-07-29), batching WishList’s per-member resolution
to 1 + levels queries per page via WLM’s own
member_ids_by_status( 'with_active_level', … ). The 202-query figure describes
code no longer on main. With WishList removed from the exclusion,
FREE_ELIGIBLE_PLATFORMS == KNOWN_PLATFORMS — the gate blocks nothing.

The alternative originally rejected here — “defer WishList and fix its N+1
first,”
dismissed as plugin-side work of unknown size — turned out to be ~200
lines landing the same day. The unknown size was the entire reason for rejecting
it.

2. The 83% was queries, not time — and time is what breaks

These are different quantities, and conflating them is what made the platform
framing look compelling. Per-page query count is constant across scale bands
(201 at every band). Per-page time is not — from
docs/superpowers/reviews/data/wp-bench-{500,1000,5000,25000}.json, key
modules.wishlist:

band (members/platform)wishlist page_ms_medianpage_queriesms/query
50018.3ms2010.09
1,00025.2ms2010.13
5,00073.1ms2010.36
25,000386.5ms2011.92

The two sources disagree slightly on the query count because they are different
harnesses covering different module sets: #784 reports 243 across ten modules
with WishList at 202, while summing modules.*.page_queries in these band files
gives 211 with WishList at 201. That 211 is not a ten-module sum — only five
modules publish page_queries at all (wishlist 201, edd 4, pmpro 3,
memberpress 2, memmouse 1), because only those fetch member pages. The
engagement modules publish engagement_queries instead (27 across the six that
have it; memmouse appears in both), and that is excluded from 211. So WishList’s
95% share of 211 is a share of page-fetch queries only, inflated by the omission —
#784’s ~83% of 243 is both the wider denominator and the share the decision was
actually made on. Either way the point below is unchanged, since query count is
constant across bands and wall clock is not.

Neither 243 nor 211 is what a composed page costs. Both are sums of
standalone per-module measurements. The same band files report
members_page.queries = 36 at every band — the composed /members page across all
ten platforms. So the “83%” was always a share of a synthetic total, not of the
query cost the sync path actually pays. This cuts the same direction as everything
else here.

Using each band’s own measurement, WishList is 0.2–0.3% of sync wall clock at
the 500–1,000 bands, rising to ~5% only at 25,000 — a size where the sync is
already ~54× over budget for reasons unrelated to WishList.

Derivation, so this is reproducible rather than asserted. Two inputs:

  • Numerator — WishList time per platform-sync = (members / 100) × page_ms_median,
    taking each band’s own row from the table above. Page size is 100:
    wordpress/dev/memberintel-bench/bench-modules.php times get_members(0, 100)
    and composes the page at per_page=100, matching Pagination_Helper’s default
    and src/memberintel/api/sites/sync.py’s WP_PER_PAGE.
  • Denominator — total sync wall clock from the fit `wall = 0.077 s/member-per-platform
    • 8.3s, stated directly in docs/superpowers/reviews/2026-07-28-ingest-cost-measurement.md` (“wall = 0.077
      s/member + 8.3s fixed”, break-even ~360 members per platform vs the 36s budget).
bandpages× page_ms_medianWishListwallsharewall vs 36s
500518.3ms0.09s46.8s0.20%1.3× over
1,0001025.2ms0.25s85.3s0.30%2.4× over
5,0005073.1ms3.65s393.3s0.93%10.9× over
25,000250386.5ms96.6s1,933.3s5.00%53.7× over

These are upper bounds. The per-module figures come from a standalone harness
rather than from decomposing the composed page, so the two inputs are still from
different measurement contexts — but the error is bounded in the safe direction.
At three of four bands WishList’s standalone page_ms_median already exceeds the
composed members_page.wall_ms_median for all ten modules together
— 18.3 vs
17.3ms at 500, 73.1 vs 49.1ms at 5,000, 386.5 vs 197.3ms at 25,000, widening with
scale. (Band 1,000 is the exception: 25.2 vs 26.7ms, standalone marginally under.)
Standalone module time therefore overstates WishList’s in-page contribution at
every band that matters, which only strengthens the conclusion.

At ~360 members/platform, where the budget actually breaks, WishList accounts
for roughly 0.07s of 36s (3.6 pages × 18.3ms against 0.077 × 360 + 8.3).
Removing it moves the break-even by approximately nothing.

An earlier draft of this analysis claimed 4.5% and a 360→377 shift by applying the
25,000-band page time to a 1,000-band sync. That was wrong by ~15× and is
corrected on #784. Post-#838 wall-clock impact is unmeasured — re-run the bench
rather than trusting either arithmetic pass.

3. The measured constraint is on a different axis entirely

From docs/superpowers/reviews/2026-07-28-ingest-cost-measurement.md: Free costs
$0.12–0.39/tenant/month against a ≤$1.10 target, roughly an order of magnitude
of headroom. “Cost was never the binding constraint — capacity is.”

Capacity is wall = 0.077 s/member-per-platform + 8.3s against a per-site sweep
budget, and the site is dropped silently when it doesn’t fit, because
find_and_claim_due_sites stamps last_sync_attempted_at at claim time (#784,
still open; PR #819 made the skips visible without preventing them).

The budget is 32s, not the 36s the report states — and the difference is #819’s.
The report derived 540s / SWEEP_ROUNDS_PER_TICK 15 = 36s from Cloud Run’s hard
request kill. #819 then added SWEEP_SOFT_DEADLINE_SECONDS = 480
(src/memberintel/api/sites/service.py), which cancels still-running syncs and counts them
skipped_over_budget, so the same derivation against the live deadline gives 480/15:

per-site budgetbreak-even
as published in the #750 report36.0s~360 members/platform · ~1,800 total rows
live on main32.0s~308 members/platform · ~1,540 total rows

The “total rows” column assumes the 5-platform full-stack shape that both the
#750 report and #784 measure. The fit is per member per platform, so the
conversion is ×5: ~360 × 5 = ~1,800 (the report’s own figure) and ~308 × 5 = ~1,540. Every total-row number in this ADR carries that same divisor.

Read this as an average, not a per-site timeout. Nothing enforces 32s on an
individual site. The deadline is applied once to the whole fan-out —
asyncio.wait(tasks, timeout=SWEEP_SOFT_DEADLINE_SECONDS) in sweep_due_sites
and 15 is serial depth, “how many syncs one slot can finish inside the budget”
(SWEEP_ROUNDS_PER_TICK’s own comment). So 32s is what each site must average for
a fully-claimed sweep to finish, not a ceiling any one site hits. A sweep that
claims well under SWEEP_DUE_LIMIT gives each claimed site far more than 32s — up
to the full 480s at depth 1.

That is why the failure is intermittent rather than deterministic, and it makes
the silent-drop problem worse, not better: the same oversized site syncs fine on a
quiet tick and is cancelled on a busy one, with only skipped_over_budget to say
so. The break-even figures are the size above which a site cannot fit its share of a
full claim, which is the case that matters — SWEEP_DUE_LIMIT is 45 since #825
and load grows toward it.

The fix that made the skips visible also moved the cliff ~15% closer (360 → 308
members/platform). Tables elsewhere in this ADR are stated against the report’s 36s
so they remain checkable against their source, but converting them to the live 32s
does not take one adjustment: break-even sizes fall ~15%, while over-budget
multiples rise ~13% (band 500 is 1.3× over 36s and 1.5× over 32s). The live figure
is worse on both columns, in opposite arithmetic directions. Two reasons even ~1,540 is
optimistic: the fit is a floor value (loopback, warm local MySQL, no network),
and a tick overlapping a connect-triggered background sync gets fewer than 15
rounds, since both share the admission gate.

So the constraint scales on members × platforms × time, not platform identity.
A site running MemberPress alone — one platform, no connector fan-out, nothing the
allow-list would ever have touched — breaks the same budget on member count alone.
#784’s own scenario table, at its measured 0.311s/page:

shapepagessecondsvs 36s
5 billing platforms × 25,000 (measured)1,250388.4s10.8× over
1 billing platform @ 25,00025077.7s2.2× over
1 billing platform @ 5,0005015.5s0.4× ✓

Break-even on one billing platform is roughly 11–12k members — and these are
WP-side /members fetch only (no /memberships, /transactions,
/subscriptions, /content, no backend upserts, no network), so the real per-site
figure is strictly worse and that break-even strictly lower. Excluding one platform
cannot fix a constraint that scales on a different axis — the allow-list, even with
its exclusion intact, would not have prevented a single silent stop above that size.

Do not apply §2’s fit to a single-platform site. wall = 0.077 × members + 8.3s was fitted with all ten modules active at a per-platform band (the #750
report’s two bands are 500 and 1,000 members per platform, 2,500 and 5,000 total
rows). Its x-axis is members-per-platform on the full stack, so feeding it a
one-platform member count overstates by the platform multiplier — the two tables
above show that multiplier is ~5× at 25,000. The single-platform case has its own
measurement; use it.

And it is tier-independent. The budget does not vary by tier, so a Pro site
above ~1,540 member rows on the full stack — or ~11–12k on one platform — silently
stops too, and because Pro syncs daily rather than weekly it fails ~7× more often.
That makes this a paying-customer defect, not a Free-tier one — which is the strongest reason the platform allow-list was the wrong
place to spend effort.

Neither merged PR changed it. #825/ADR-0040 lifts SYNC_ADMISSION_SLOTS 1→3 and
SWEEP_DUE_LIMIT 15→45, but leaves SWEEP_ROUNDS_PER_TICK at 15 and derives the
claim limit as slots × rounds, so the per-site share is untouched — its own
comment says rounds-per-tick “is concurrency-independent … so it stays 15.” It
bought more tenants, not bigger sites; in fact a fuller claim makes the 32s average
bind more often, not less. #838 batched WishList’s N+1, worth ~0.06s of the 32s
at the size where that budget breaks (3.08 pages × 18.3ms at ~308
members/platform); §2’s ~0.07s is the same calculation against the report’s 36s and
its ~360.

The durable fix is #852 (one queued task per site), not #818. Raising
SWEEP_ROUNDS_PER_TICK only trades tenant throughput for per-site headroom — at the
same 5-platform shape, 5,000 rows (1,000/platform, wall 85.3s) needs rounds ≤
480/85.3 = 5.6, cutting sites/tick from 45 to 15, and 25,000 rows (5,000/platform,
393.3s) needs rounds ≤ 1.2, which is not a system.

And incremental sync (#818) does not remove the coupling either — it reduces the work
per cadence, which buys a larger N before the same failure returns. Any design where
one request drains a queue of sites serially has this waiting at some fleet size.
#852 severs it: each site gets its own request and its own timeout, so per-site
allowance stops being a function of tenant count. Credit to @omaraelhawary for the
reframe (#851 review, #852).

Worth recording that #852 is also the cheaper option on correctness, which is not
obvious from the coupling argument. It keeps sync_site intact behind a per-site
endpoint, so one task still syncs one whole site and the “this fetch is a complete
authoritative snapshot” invariant survives untouched. #818 — and any design that
chunks one site across ticks — breaks that invariant: absence gains a third meaning
(“unchanged”) that the engagement clear (sync.py:578, absent-means-tracked-and-zero)
and content pruning (sync.py:711) cannot distinguish from “gone.” Repairing it needs
a delta path plus a periodic full reconcile. #852 needs none of that.

Consequences

Positive:

  • No customer is turned away from Free over which membership plugin they run — a
    packaging decision the evidence never supported. WishList in particular is a
    billing platform, so excluding it meant “a WishList-powered site cannot use
    Free at all,” not “an add-on needs Pro.”
  • ~870 lines of refusal machinery not carried. Measured as the cumulative branch
    diff at the last implementation commit, git diff --numstat 4a32368c 09a60a0b
    (4a32368c is the merge base): 1,077 insertions, 205 of them the ADR itself,
    leaving 872 — 276 lines of source across six files, 596 of tests. (git show 09a60a0b alone is only 68 insertions; it is the branch tip, not the whole
    implementation.) With nothing to refuse it was dead code under this repo’s “no
    just-in-case code” and “one source of truth” rules —
    and worse, it read like a live control. A future reader would have concluded Free
    connector eligibility was enforced and not gone looking for the silent stop.
  • The next limit gets built on the axis the measurements actually implicate.

Negative / costs:

  • No Free-tier bound ships from #787 at all. The gap #787 identified is real and
    stays open until a size-based bound lands — in whatever form, which #844 settled as
    cadence rather than a cap.
  • If a genuinely expensive connector appears, the connect and sync enforcement
    seams must be rebuilt. Accepted: the design is recorded here and in #834’s
    history, and a second build from a written design is cheaper than the first.
  • The completeness forcing-function (test_every_known_platform_is_classified) is
    dropped too. It would have made every new connector answer “may Free activate
    this?” — but with the answer permanently “yes,” it asserted a tautology while
    taxing every future connector author.

Mitigations / follow-ups:

  • #852 (one queued task per site) is the durable fix, and the priority. It
    decouples per-site allowance from fleet size, which is the structural bug; it hits
    paying customers as much as Free, so it outranks any tier-limit work. #851
    closes the fairness half in the meantime (an oversized site can no longer starve the
    sites queued behind it, and the overrun is recorded as over_budget rather than a
    sync failure). Parent: #784.
  • #818 (incremental sync) is complementary, not the fix. It remains worth doing on
    cost grounds — Cloud Run bills request duration and ~87% of a sync is waiting on
    WordPress, so a delta cuts both our bill and the load on the customer’s host. It is
    not what removes the coupling, and it carries hard problems #852 does not: no
    billing module exposes a modification timestamp, and MemberPress derives status
    (class-memberpress-module.php:156), so an expiry changes a member’s state with
    zero database writes and is invisible to any ?since=. Findings written up on
    #818.
  • A Free size limit is deferred, and its shape is decided if it returns.
    Explored and closed as #844: degrade cadence (weekly → monthly above N
    members), NOT a hard member cap. A cap forces a bad choice — refuse to sync (a
    500-member site gets no dashboard) or truncate (partial data reported as complete,
    the same defect class as #784). Cadence degradation keeps data complete and
    truthful, and decouples N from the capacity math so N is a packaging choice rather
    than a derived number. It is deferred rather than adopted because cadence controls
    when a site is due, not how long its sync may take — an oversized site still
    breaks the budget, monthly instead of weekly — and because #852 (and #818 behind it)
    changes the cost model any N would be derived from.
  • Re-measure post-#838 before anyone cites a WishList cost figure again.
  • Derive budget figures from the constants, not from documents. The per-site
    divisor moved once, and tightened: #819 added
    SWEEP_SOFT_DEADLINE_SECONDS = 480, so the derivation went from Cloud Run’s
    540/15 = 36s to 480/15 = 32s — and no document followed. #825 changed
    SYNC_ADMISSION_SLOTS and SWEEP_DUE_LIMIT around it without touching
    SWEEP_ROUNDS_PER_TICK, which is the trap: constants next to the one you care
    about can move in the same window and leave it deceptively unchanged.
  • If per-platform cost ever becomes a tier input, it should be cost weights in a
    budget
    (#787’s option 3), not an eligibility list. A weighted budget needs a
    projected-cost number per platform — a different data structure answering a
    different question, so today’s slug list would not have been reused.

Alternatives considered

  • Free-eligible allow-list (implemented, then withdrawn) — rejected on all
    three findings above. The full implementation is in PR #834’s history if it is
    ever needed.
  • Hard count cap (e.g. one billing + one engagement platform) — rejected:
    ignores a per-module cost spread of 29–67× in queries per page — WishList’s
    202 against every other module’s 3–7, from #784’s table — so it prices the wrong
    thing. “Three modules including WishList” cost more than five cheap ones.
  • Weighted ingest budget per tier — the right eventual shape if platform cost
    re-enters, but it needs per-module $/tenant figures to calibrate and no
    platform has one
    : the cost report prices whole-site syncs, never a module. Query
    counts are not the gap — the report’s table names six of ten (pmpro, memmouse,
    learndash, coursecure omitted, which is the sense in which the gate review
    calls them unmeasured), and the band files cited in §2 cover all ten. Deferred,
    not rejected.
  • Ship the mechanism as scaffolding with an empty list (Omar’s position on
    #834) — rejected, but it was a genuine call with a real retrofit cost on the
    other side. The completeness test does not need the surrounding refusal machinery
    to exist (~20 lines vs ~870), and a future weighted budget would not reuse a
    fixed slug list.
For: S Seth Shoultes A AI Engineer B Blair Williams S Santiago Perez Asis P Product Lead