ADR-082: Reputation Disclosure Boundary
ADR-082: Reputation Disclosure Boundary
Status: Implemented Date: 2026-06-24 Sprint: 112 (contract) → 113 (UI defense-in-depth + two-user validation) Version: 11.18.0 → 11.19.0 → 11.20.0 Supersedes (partially): the graph-node numeric reputation contract from ADR-081
Context
Sprint 111 (ADR-081) made the belonging graph coherent and added graph-node metric redaction, but post-deploy validation exposed a deeper, platform-wide inconsistency:
- Terminology drift. The same member, same community, showed "trust 120 · 40 karma" on one surface and "Trust Score 27/100 · Karma 0" on another. These are different models — graph-node relationship weight, normalized 0–100 reputation, raw karma, decayed karma — all rendered as interchangeable "trust"/"karma," making a correct system look unreliable.
- Local privacy fix. Sprint 111 redacted non-caller graph-node metrics in React, but governance, trust cards, path intermediates, invitations, leaderboards, and community exports could still reveal another ordinary member's exact reputation. Hiding values in the client is not a durable boundary; the API response itself must be safe.
Decision
Establish one platform-wide reputation disclosure policy, enforced at the API boundary.
Core principle: Karmyq may show how people and communities are connected, but an ordinary member's exact reputation numbers belong only to that member.
Disclosure classes
Every reputation-bearing outward contract belongs to exactly one class:
self— exact community-scoped reputation, returned only to the authenticated caller.ordinary_member— authorized identity + structure + a coarse relationship/eligibility explanation. No exact reputation. Strict schemas rejecttrust_score/karma/*_weight.provider— opted-in public service-provider ratings (an explicit numeric exception; a person's provider role, not their private mutual-aid reputation).community_aggregate— non-identifying community totals, returned only with a ≥5 distinct-member cohort so they cannot be decomposed to an individual.internal— community-admin policy surfaces (decay/evolution config); never carry member rows.
There is no administrator exception for browsing another ordinary member's exact reputation. Governance and community exports follow the same rule.
Enforcement (defense in depth)
- Query minimization — do not select another member's metrics when the response doesn't need them.
- Explicit projection — map internal rows to strict outward DTOs at the response boundary; never spread database rows into responses.
- Strict shared schemas —
@karmyq/sharedreputationDisclosureZod schemas (self, ordinary-member graph/path/governance, provider, aggregate) are.strict(), so an accidental row spread fails projection tests rather than leaking. - Cross-user tests — authenticate as one member, seed another member's non-zero sentinel values, assert they are absent from the response.
- CI disclosure gate —
tests/fixtures/reputation-disclosure-inventory.json+services/registry.json#reputation_disclosureare cross-checked in both directions bytests/regression/reputation-disclosure-gate.test.ts; a new reputation-bearing endpoint cannot ship unclassified, schema-less, or test-less, and protected fixtures are scanned for forbidden keys at any depth.
New canonical self contract
GET /reputation/me/community-summary?community_id= returns one SelfCommunityReputation (scope +
reputation score/tier + decayed karma/trend + recent activity, with scale/window/timestamps).
Profile, Home, and My Network consume this single read instead of recombining karma + trust + graph
fields. UI copy standardizes on Reputation score (0–100) and Current karma (decayed).
Compatibility behavior
- Cross-user reads of
:userIdreputation/config endpoints return ADR-074404 REPUTATION_NOT_FOUND(not 403 — we do not confirm a user has reputation data). No admin exception on trust-config. - Community aggregates require active membership + ≥5 cohort, else
404 AGGREGATE_NOT_AVAILABLE. (Community-trust only: amended in Sprint 129 to200 { data: null }. See the amendment at the end.) - The member leaderboard is retired:
410 REPUTATION_LEADERBOARD_RETIRED, no rows. GET /trust/edgeis retired:410 TRUST_EDGE_ENDPOINT_RETIRED(internalgetTrustEdge()kept).- Person graph nodes/links, trust cards, paths, invitations, governance, and exports carry identity,
structure, and a qualitative
relationship_state— never another member's exact metrics.
Relationship state
Member-facing graph contracts expose relationship_state (strong | warm | fading | nearly_forgotten, derived from the ADR-070 decay tier) instead of raw/effective edge weights. Exact
edge weights and the numeric path strength remain internal (feed ranking still uses them).
Context-bound connection visibility (Sprint 116 / ADR-084)
An authenticated member may see another person's named connection topology only while deciding whether to help on a reachable request, or while either participant reviews an ordinary/provider offer on that request. The request and offer records derive both participant IDs server-side; there is no arbitrary target-user route, member search, profile graph, or administrator bypass.
This ordinary_member contract adds a coarse bond_depth on disclosed edges:
forming— fewer than two recorded completed interactions;growing— at least two recorded completed interactions;established— at least four recorded completed interactions.
Those bands intentionally disclose ordinal floors (growing ≥2, established ≥4). That is a real,
documented privacy trade-off made to give line thickness a legible history meaning. Exact counts,
raw/effective weights, timestamps, karma, reputation, exchange content, and numeric path strength
remain forbidden. Brightness carries no relationship meaning.
Defense in depth — UI layer (Sprint 113 / PR A)
PR A (Sprint 112) closed the boundary in the API contract. Sprint 113 PR A makes the boundary
true on the screen — the client must render the safe contract correctly, not reintroduce a leak or
a NaN where a field was intentionally omitted. Three rules, all enforced by frontend TDD tests:
- No
NaNon a possibly-absent field (BUG-025). Governance/stewardship payloads project each member row to identity + a coarseeligibility_reasononly — notrust_score/karma. The UI must neverMath.round/Number/.toFixedan omitted field, and never paper over absence with|| 0(a fake zero is itself a disclosure anti-pattern). Omit the element or show a qualitative label (e.g. governance eligible-members now read "Eligible · established community relationships"; role-holders show identity + role with no trust number). Community aggregates that the contract still returns (e.g.maturity.avg_trust_score) are unaffected. - One canonical self read (BUG-024/026). The member's own profile reads its reputation from the
single
GET /reputation/me/community-summarycontract — never by separately recombininggetMyKarma+getTrustScore, which was the dual-source mismatch that showed a member different numbers on profile vs. the community view. Profile copy standardizes on Current Karma and Reputation Score (0–100). Scope of this PR:profile.tsxis migrated.LeftSidebarkeeps its direct self trust read (it must also serve the no-community "overall" case the summary endpoint can't); its number is consistent because it reads the same underlying trust score. The community member-topology surface's per-membergetTrustScorereads (useCommunityData) are cross-user and now correctly receive404 REPUTATION_NOT_FOUNDfrom the boundary. - Restore the map zoom, single owner (BUG-027). Every belonging-graph surface routes through one
wrapper (
BelongingGraph) → one renderer (TrustGraphHEB). Zoom controls (in/out/reset) mount only inside the renderer and are gated byenableZoom(now default-on in the wrapper), so no surface double-mounts controls. The zoom behavior pins an explicit.extent()so the button-drivenscaleBy/transformstay deterministic and testable in jsdom.
These are validated by a two-user check (non-zero sentinels) before ADR-082 flips to Implemented.
Consequences
- Positive: one consistent, truthful self-facing language; a durable API-first privacy boundary; a regression gate that prevents new leaks; provider accountability and community health preserved as explicit exceptions.
- Costs / trade-offs: other members' exact reputation is no longer visible anywhere (intended); the belonging graph shows structure, not node scores; a one-release compatibility window returns 410/404 for retired/cross-user endpoints before old clients are fully migrated.
- No database migration and no reputation-math rewrite — ADR-037/038/039 scoring, ADR-011 decay, governance thresholds, vote weights, matching, and background jobs are unchanged; only naming, projection, authorization, and client consumption changed.
Delivery
Two ordered PRs: PR A (this) ships the API-enforced boundary + CI gate + canonical self summary
- frontend contract alignment. PR B adds My Network navigation prominence, the Home preview, the shared self-summary UI, and retires the leaderboard UI — built only on PR A's safe contracts.
Sprint 129 amendment (2026-09-14): a denied community-trust aggregate is an empty state
GET /reputation/community-trust/:communityId no longer denies with 404 AGGREGATE_NOT_AVAILABLE.
It answers 200 { "success": true, "data": null } (BUG-031).
Why. /communities asks for this aggregate once per card. For every card the caller may not see,
the 404 surfaced as a console error: 32 on a single demo page load. Denial is the normal case on a
discovery page, since most cards are communities the caller doesn't belong to, so it was being
transported as a failure.
What is preserved, and how it is proven. The property this ADR cares about is that the caller
cannot tell why there is no aggregate. checkAggregateAccess denies an unknown community, a
non-member and an undersized cohort alike, and all three still reach one shared exit
(denyAggregate in routes/reputation.ts), which must not branch. The new response is also exactly
what a permitted caller already received when no score could be computed. A denial is now
indistinguishable from a genuine empty state, which reveals strictly less than the old 404 did.
services/reputation-service/tests/regression/sprint-129-community-aggregate.test.ts compares the four
responses to each other byte for byte, and asserts that a denied caller never reads or computes the
aggregate, even with ?recalculate=true.
Explicitly not done. No 404 for "no such community": that would make existence observable,
which is exactly the leak this ADR prevents.
Unchanged. community-health, milestones and network-metrics (routes/health.ts) keep their
own 404 AGGREGATE_NOT_AVAILABLE. None is fanned out per list item, and changing them was out of
scope. The self-only 404 REPUTATION_NOT_FOUND contract is also untouched, including for
GET /trust/:userId/:communityId. Its un-migrated per-member client caller is tracked as BUG-042,
and the fix there is to remove the fan-out, not to change the status.
Sprint 130 note (2026-09-15): the client stops asking for what it cannot be given
No server contract changed. Two client fan-outs that this ADR guarantees will be denied were removed instead:
/communitiesasked for the community-trust aggregate of every card in the discovery grid. That grid only shows communities the caller has not joined, and the aggregate is granted only to an active member, so every one of those requests was denied (BUG-044). The page now asks only for the caller's joined communities and shows the badge on their "Your Communities" chips.- The community People tab asked for
GET /trust/:userId/:communityIdfor every active member. That route is self-only and answers404 REPUTATION_NOT_FOUNDfor anyone else (BUG-042). The per-member fan-out and its score pill are gone; the self-only 404 is unchanged.
The Sprint 113 description of those per-member 404s as expected during a client migration window is
now historical: the migration is complete. Regression coverage: apps/frontend/tests/regression/sprint-130-reputation-fanout.test.tsx.