decision
ADR-0042: The plugin's badge wall is site-scoped; user-level achievements stay app-only
ADR-0042 (Accepted, 2026-07-29): The plugin's badge wall is site-scoped; user-level achievements stay app-only.
Status: Accepted
Date: 2026-07-29 (accepted 2026-07-30)
Deciders: Ronaldo Reymundo (spec author); Seth Shoultes signed off in memberintel#848 — “Agreed — decline the plugin read path… A site API key must not become a window onto the owner’s portfolio-wide activity”, accepting the thinner three-badge wp-admin wall as a product cost. Departed from the approach he originally requested in memberintel#732.
Numbering note. This ADR was drafted as 0040. Both 0040
(0040-cloud-sql-tier-for-sync-concurrency) and 0041
(0041-free-tier-bounded-by-member-count) landed onmainwhile it was in
review, so it moved to 0042. Older references to “ADR-0040 (plugin badge
wall)” mean this document.
Context
memberintel#732 ships achievement badges. Seth asked (in the issue’s follow-up comment) for
the badges to appear in both surfaces — the member app and the memberintel-connect
wp-admin screens — and proposed the plugin read path as: resolve site → owner user → that
user’s achievements, exposed through a plugin-authed endpoint mirroring how Reports and
Chat already proxy backend calls.
The two surfaces have different identity models. The app is user-authenticated (JWT →
MemberIntel user). The plugin is site-authenticated — it presents the site’s API key and
resolves to a Site, with no user login (src/memberintel/api/sites/plugin_auth.py:24,
src/memberintel/api/plugin/deps.py).
The proposed resolution conflicts with a boundary the codebase already enforces
deliberately. The existing plugin-authed chat read path
(src/memberintel/api/chat/router.py:864) documents it:
“Site-scope is the security boundary (mirrors the spec-C scope-confusion fix): filtered to
scope="single"ANDsite_id ==the resolved site’s id, so a plugin key can never see a
same-owner other-site or portfolio conversation.”
Badges split cleanly along that line. Connected and Plugged In are properties of a site.
Onboarded is a property of the user but carries no cross-site information — it is a binary
about the account holder. First Question and the Advisor tiers (5 / 10 / 50) are counts
aggregated across every site the owner has, plus portfolio-scope conversations. Serving
advisor_50 to a site API key would tell the holder of one site’s key how much the owner
uses the advisor everywhere else — precisely the cross-site inference the chat route blocks.
Worth recording because the issue raised it as an open edge case: “a site with no or a
different owner” cannot happen. Site.user_id is a non-nullable FK to users.id
(src/memberintel/db/models.py:145), so every site has exactly one owner. The real case is
the inverse — one user owning many sites — which is exactly what site-scoping addresses.
Decision
The plugin’s badge wall shows only site-scoped achievements. No site → owner → all-achievements read path is built.
- The plugin-authed read exposes Connected and Plugged In for the resolved site,
plus Onboarded for its owner (user-level, but carrying no cross-site information). - First Question and the Advisor tiers are app-only. They are not served to a
site-authenticated caller in any form. - Every badge keeps one definition across both surfaces. We do not re-scope Advisor
tiers to per-site counts, which would make “Advisor 10” mean portfolio-wide in the app
and this-site-only in wp-admin. - The site-scope boundary asserted at
chat/router.py:864is treated as a standing
invariant for all plugin-authed reads, not a chat-specific quirk. New plugin endpoints
inherit it.
Considered options
- Widen the boundary as requested. Serve the owner’s full achievement set to the plugin
key. Simplest read path, one definition per badge, and what the stakeholder asked for.
Rejected at spec level: it reverses a documented security decision, so it is not a spec
detail to be waved through — it needs an explicit decision recorded as its own ADR, and
arguably a security/counsel look, since it turns a site API key into a window onto
portfolio-wide activity. - Re-scope the counts per surface. Same badge set in the plugin, with Advisor tiers
counted from the resolved site’s conversations only. Keeps the boundary and a full wall.
Rejected: one badge name with two meanings is a domain-model smell, and site-scoped tiers
would need their own earned rows, roughly doubling the achievement keys.
Consequences
Positive:
- No new exposure surface: the plugin read returns nothing a site key could not already
infer from its own site. - The site-scope invariant now has an ADR behind it, so the next plugin endpoint has a rule
to follow rather than a comment to discover. - One definition per badge keeps
CONTEXT.mdhonest.
Negative / costs:
- The wp-admin wall is thinner than Seth pictured — three badges rather than the full
set. This is a product cost, and it is the reason this ADR exists: if the full wall
matters more than the boundary, that is Seth’s call to make explicitly, and it supersedes
this ADR rather than quietly bypassing it. - Operators using two surfaces see different badge counts in each, which needs a line of
copy in wp-admin pointing to the app for the full set.