Reputation Service
22
API Endpoints
1
Service Deps
3
Infrastructure
1
DB Schemas
API Endpoints
/reputation/karma/:userIdGet user's total karma across all communities.
/reputation/trust/:userIdGet user's overall trust score — weighted average across all communities, weighted by recent interaction count.
/reputation/trust/:userId/:communityIdGet user's trust score in a specific community.
/reputation/leaderboard/:communityIdGet top karma earners in a community.
/reputation/history/:userIdGet karma transaction history for a user.
/reputation/users/:userId/badgesGet all prestige badges earned by a user (public). Phase 1 badge types: `first_helper`, `milestone_10`, `milestone_50`, `milestone_100`, `connector`.
/reputation/community-health/:communityIdGet community health metrics and trends.
/reputation/milestones/:communityIdGet community milestone achievements.
/reputation/trust/:userId/:communityIdEnhanced trust score with interaction quality (UPDATED).
/reputation/network-metrics/:communityIdGet the network cohesion score for a community (ADR-045). Four graph topology metrics — reciprocity, density, clustering coefficient, and path score — over a rolling 90-day window.
/reputation/community-trust/:communityIdGet the community trust score (ADR-040). Computed daily; recalculates on demand if no score exists yet.
/reputation/feedback (Authenticated)Submit a private quality rating after a completed interaction. Ratings are internal trust signals — never exposed to users (ADR-036).
/healthService health check.
/reputation/trust-config/:userId/:communityIdGet user trust config and effective parameters (merged community defaults + per-user overrides).
/reputation/trust-config/:userId/:communityIdToggle `evolution_enabled` for a user in a community (user or admin).
/reputation/trust-config/:userId/:communityId/historyReturn the immutable evolution log for a user — every parameter adjustment with trigger signal and event ID.
/reputation/communities/:communityId/trust-evolutionGet community-level evolution status (admin only). Returns whether evolution is enabled and the community cross_community_prior.
/reputation/communities/:communityId/trust-evolutionToggle community evolution on/off (admin only).
/reputation/community/:communityId/evolution/historyAdmin — paginated community evolution log showing parameter changes over time.
/reputation/community/:communityId/evolution/summaryAdmin — drift summary: first evolution date, evolved parameter count, last contributing member count.
/reputation/community/:communityId/evolution/toggleAdmin — enable/disable community evolution engine. Body: `{ "enabled": boolean }`.
/reputation/users/:userId/effective-params?communityId=Returns blended trust params from Redis cache (4h TTL), falls back to DB on cache miss. Auth: self only.
/reputation/users/:userId/evolution-globalReturns the user's global evolution opt-in preference. Missing row = true (default opt-in). Auth: self only.
/reputation/users/:userId/evolution-globalSet global evolution enabled/disabled for the user. Body: `{ "global_evolution_enabled": boolean }`. Auth: self only.
Infrastructure
Service Dependencies
Publishes Events
Subscribes To
Full Documentation
Reputation Service Context
Quick Start:
cd services/reputation-service && npm run devPort: 3004 | Health: http://localhost:3004/health
Recent Changes
-
2026-10-05 (Sprint 132 PR C, ADR-099): new completion jobs arrive on
karmyq-completion-reputationfrom request-service's durable fanout dispatcher. The same canonical standing/badge/evolution handler also remains onkarmyq-eventsfor legacy jobs. Independent completion queues prevent notification/social workers consuming reputation's job. Standing projection remains transactional and idempotent; no standing policy changed. -
2026-09-15 (Sprint 130 PR A — the frontend stops asking for denied reputation): No server change. Two frontend call patterns changed:
/communitiesrequestsGET /reputation/community-trust/:communityIdonly for the caller's joined communities, not for every discovery card (BUG-044). About 37 requests per demo load become the member's joined count.- The community People tab no longer requests
GET /reputation/trust/:userId/:communityIdper member (BUG-042).LeftSidebar's self read is the only remaining caller of that route. - The self-only 404 and the aggregate's
200 { data: null }denial are untouched. Tests:apps/frontend/tests/regression/sprint-130-reputation-fanout.test.tsx.
-
2026-09-14 (Sprint 129 PR C — BUG-031: a denied community-trust aggregate is an empty state):
GET /reputation/community-trust/:communityIdused to deny with404 AGGREGATE_NOT_AVAILABLE. It now answers200 { success: true, data: null }./communitiesrequests this once per card, so every card the caller could not see logged a console 404.- All three denial causes (unknown community, non-member, cohort < 5) still share one exit,
denyAggregateinroutes/reputation.ts. Do not branch inside it, and never add a 404 for an unknown community: that would leak existence. The denial is also byte-identical to a permitted community whose score cannot be computed, which already returneddata: null. - A denied caller never reads or computes the aggregate, not even with
?recalculate=true. - Proven by
tests/regression/sprint-129-community-aggregate.test.ts, which compares the responses to each other. The two Sprint 112 regression cases were moved to the new contract. ADR-082 carries the amendment. - Unchanged on purpose:
routes/health.tshas its owndenyAggregateforcommunity-health,milestonesandnetwork-metrics, which still returns 404. None is fanned out per list item. - Frontend:
pages/communities/index.tsxreadresponse.data.data.score, which is always undefined after the api client's unwrap. It now readsresponse.data.score. ⚠️ Correction (Sprint 130): this did not make the badge render. It sat on discovery cards, where the caller is never a member, so no score could exist (BUG-044). Sprint 130 moved it. - Found, not fixed: BUG-042 (the People tab fans out to self-only
/trust/:userId/:communityId) and BUG-043 (/communitiesdouble-fetches when the saved discovery mode is "interests").
- All three denial causes (unknown community, non-member, cohort < 5) still share one exit,
-
2026-09-10 (Sprint 128 PR C — the backfill preview now agrees with the score writer):
analyzeStandingBackfillused to derive its trust inputs from the replayed match list, which is not what the writer reads. It therefore saw neither pre-existing canonical history nor activity in other communities, and dropped a membership's metrics to all zeros whenever the local pair had no replayed match. Because breadth is global, those memberships stored 1 while the report printed 0 — and the same score gap propagated intoproviderEligibility.- New pure helper
src/services/standingPreview.ts:buildPreviewIndex(rows)once per projected dataset, thencomputePreviewMetrics(index, userId, communityId, nowMs)per membership — map lookups plus a binary search over sorted local timestamps, no per-membership rescan. - It reproduces the writer's SQL, not the replay map: global community count; local recent row
count with an inclusive
>= now-365dboundary; counterparty join on non-null match id with theotherside not community-filtered; repeats by distinct match id. It filters on exactly('Provided help', 'Received help')— narrower thanCANONICAL_REASONS, which also holds the first-help and milestone reasons that the writer's SQL ignores. calculateDistributionsnow scores the post-apply karma view, built by mirroring apply's three mutations in order: normalize unattributable legacy reasons (collapsing on collision), delete legacy rows attributable to a replayed match, then overlay the planned canonical rows by identity so an already-projected row is not counted twice.- No change to the trust formula, provider floors,
PROVIDERS_QUERYfilters, the report's fields, the apply authorization boundary, or any endpoint/schema/event. Verified on a disposable PostgreSQL 15 + Redis 7 instance by running the real projector and the realupdateTrustScoreand comparing exact score buckets and provider-floor counts (tests/integration/sprint-126-standing-backfill.integration.test.ts, "Sprint 128 — preview equals the real writer"). See the runbook below for what a score of 1 means and why it is not a floor.
- New pure helper
-
2026-08-22 (Sprint 126 review round — trust-score refresh, anomaly severity, carry lineage):
src/cron/trustScoreRefresh.tssweeps every active membership daily at 03:30 through the canonicalupdateTrustScore.computeTrustScorereads a moving 12-month window, so a stored score decays only if something recomputes it — and ADR-095's reach gate reads the CACHED value, not a fresh one. When Sprint 126 stopped cleanup-service overwriting scores with its pre-ADR-037karma/10formula, that removed the only refresh cadence and would have frozen dormant providers as permanently eligible. Reputation-service owns trust scores, so the refresh lives here.- Anomaly severity splits corrupt source data from routine history. Blocking: missing participants, missing
completed_at, no request community at all, duplicate projection identities, conflicting stored projections. Informational:NO_ELIGIBLE_COMMUNITY(participants no longer co-members — routine, and guessing a community would fabricate history). Letting a match with broken source facts be silently skipped while apply reported success was fail-open. UNEXPECTED_KARMA_PROJECTIONBLOCKS unconditionally. An intermediate version made it informational when the destination community had fusion/fission lineage to a request community. That heuristic proved adjacency, not legitimacy, and was wrong four ways: it never validated the row's points, timestamp or carry semantics, so a fabricated row in a linked community passed; the adjacency was undirected, so an invalid reverse carry passed; it was single-hop, so genuine multi-generation history was rejected anyway; andcommunity_linkswas read without a status filter, so a merely PENDING link sufficed. A carried row cannot be derived from the match's own facts, so no graph-walk makes it verifiable — blocking is the honest outcome. It cannot bite the first demo backfill: carry only produces CANONICAL rows once canonical rows exist, and every stored karma row today is legacy snake_case, which the comparison skips.NO_ELIGIBLE_COMMUNITYis the only informational code.convergedon the report, and the CLI exits 1 when an apply leaves rows outstanding. Absence of anomalies is not the same as done: the live simulator completes matches during a run, so work can remain with a clean report.
-
2026-08-20 (Sprint 126 — BUG-037: live karma awarding was broken since Sprint 62):
karmaService.getCommunityKarmaConfig()selectedconfig->'enabled_request_types'fromcommunities.community_configs, which has noconfigcolumn —enabled_request_typesis top-leveljsonb. PostgreSQL raised42703at parse time, soawardKarmaForCompletedMatch()threw on every completed match. Demo confirmed it: 7,860 completed matches, 0 karma rows from the live path, 0trust_scoresrows, 0activity_logrows; the only 174 karma rows came from the curated fixture. This is also why ADR-095's reach gate emptied the provider layer at any non-zero floor. Fixed by deleting the legacy award path entirely;standingProjector.tsreads the real column. Mocked tests could not catch it — a stubbed pool asserts its own mock, never a column's existence. -
2026-08-20 (Sprint 126 — transactional standing projector, ADR-096):
standingProjector.tsis the one database adapter for the canonical policy in@karmyq/shared. One transaction per match underpg_advisory_xact_lock(hashtextextended(matchId, 0)); all writesON CONFLICT DO NOTHINGagainst the projection identities; community selection and milestone rank read history strictly before(completed_at, match_id), which is what makes a replay stable. Rows carry the storedmatches.completed_at, neverNOW(). The event payload is a message, not a record — participants and status are re-read under the lock and a disagreeing payload is rejected. Historical mode fails closed with no community; only live delivery falls back to one deterministically ordered request community.karmaService.tskeeps trust reads andupdateTrustScore(the deliberate present-state exception) and lostKARMA_DEFAULTSplus its secondMAX_COMMUNITIES_PER_KARMA_AWARD, which duplicated the shared constants. -
2026-08-20 (Sprint 126 — standing operator CLI, in progress):
npm run backfill:standingis dry-run by default and prints the complete preflight report plus the exact apply command. Only--applyreachesapplyStandingBackfill;--batch-size Naccepts positive integers and defaults to 100. Unknown, missing, non-integer, zero, and negative arguments exit 2 before any database service call. Apply progress prints batch/match counts and the last committed match ID, never user records or credentials. -
2026-08-20 (Sprint 126 — bounded standing apply, in progress):
standingBackfillService.applyStandingBackfill()now reruns fail-closed preflight, normalizes only safe unattributable legacy reason labels, reprojects attributable fixture rows oldest-first in per-match transactions, emits bounded progress, and refreshes every active membership including zero-history pairs. Database projection identities are the resume checkpoint. Exact normalization collisions collapse without losing points/timestamps; conflicting collisions abort. A post-preflight community-set change also aborts inside the match transaction so legacy deletion cannot commit without its canonical replacement. -
2026-08-20 (Sprint 126 — standing preflight, in progress):
standingBackfillService.analyzeStandingBackfill()performs a SELECT-only, oldest-first replay of completed-match facts through the shared standing policy and reports legacy provenance, conflicting/duplicate projections, predicted karma/activity writes, zero-history memberships, trust-score distributions, interaction depth/breadth, and provider reach floors. It fails closed throughcanApplywhen source facts or stored canonical projections are unsafe.feedbackDb.calculateWeightedAvgFeedback()is now the pure ADR-039 weighting function shared by live refresh and preflight. The live projector and preflight read the typedcommunity_configs.enabled_request_typescolumn; the formercc.config->...expression referenced a column that does not exist. -
2026-06-18 (Sprint 106 — BUG-013 rating-write hardening, v11.14.0):
POST /reputation/feedbackpreviously accepted a rating from ANY authenticated user for ANY match, guarding only against double-submission per(from_user_id, request_match_id). It now validates participation and lifecycle beforeinsertFeedback: newfeedbackDb.getMatchParticipation(matchId)(joinsrequests.matches→requests.help_requests→requests.request_communities, cross-schema, returns{ requesterId, responderId, status, communityIds }) drives five checks — 404MATCH_NOT_FOUND(unknown match), 403NOT_A_PARTICIPANT(caller is neither party), 400INVALID_RATEE(to_user_idis not the counterparty), 409MATCH_NOT_COMPLETED(match not yetcompleted), and 400INVALID_COMMUNITY(bodycommunity_idis not one the match's request was posted to — prevents a participant attributing feedback/trust to an arbitrary community). The per-(rater, match)double-submission guard is unchanged, so both parties still rate independently. Test:tests/tdd/sprint-106-feedback-constraints.test.ts. -
2026-05-21 (Sprint 62 — Karma multipliers):
src/services/karmaAllocation.ts—allocateKarma()now accepts optionalrequestType?: stringparameter. AddedgetRequestTypeMultiplier()helper that readsenabled_request_typesfromCommunityKarmaConfigand returns the configuredkarma_multiplier(defaults to1.0). ExtendedCommunityKarmaConfigwith optionalenabled_request_types?: RequestTypeConfig[].karmaService.ts—getCommunityKarmaConfig()now fetchesconfig->'enabled_request_types'alongside existing columns.awardKarmaForCompletedMatch()now resolvesrequest_type(from event payload or DB lookup) and passes it toallocateKarma(), so per-community multipliers are applied at match completion. -
2026-05-17 (Sprint 56 — Publisher centralization):
src/events/publisher.tsnow delegates tocreatePublisher('reputation-service')from@karmyq/shared; local 37-line Bull setup removed. Added@karmyq/sharedtopackage.jsondependencies. -
2026-05-10 (Sprint 54 — OWASP security hardening, ADR-052): Added
authMiddlewareper-route to 8 endpoints that were previously unauthenticated:GET /karma/:userId,GET /trust/:userId,GET /trust/:userId/:communityId,GET /community-trust/:communityId,GET /leaderboard/:communityId,GET /history/:userId,GET /badges/:userId,GET /users/:userId/badges. Per-route (not router-level) to avoid breaking internal service calls. Addedhelmet()+ CORS allowlist viaALLOWED_ORIGINSenv var. Access to all these endpoints now requires a valid JWT. -
2026-03-20 (Sprint 32 — Fractal Feed, ADR-046 complete):
updateTrustScore()now fetches evolveddepth_weight/breadth_weightviagetCachedEffectiveParams()(Redis cache, TTL 4h) instead of static community defaults.isEvolutionEligible()now checks global opt-out (reputation.user_trust_preferences) first. New table:reputation.user_trust_preferences (user_id, global_evolution_enabled). 3 new endpoints: GET/reputation/users/:userId/effective-params?communityId=, GET/PUT/reputation/users/:userId/evolution-global. New file:effectiveParamsCache.ts. Curated feed in request-service usescross_community_prior × 100as trust distance for null-degree requesters. ADR-046 status: Implemented. -
2026-03-19 (Sprint 30 — Trust Evolution Foundation): Per-user trust parameter evolution implemented (ADR-046). New tables:
reputation.user_trust_configs(per-user depth_weight, breadth_weight, cross_community_prior, evolution_enabled) andreputation.user_trust_evolution_log(immutable adjustment log). Addedcommunity_evolution_enabled+cross_community_priorcolumns tocommunities.community_configs. 5 new API routes: GET/PUT/reputation/trust-config/:userId/:communityId, GET/reputation/trust-config/:userId/:communityId/history, GET/PUT/reputation/communities/:communityId/trust-evolution. Evolution signals wired intomatch_completedBull event handler. Inline feedback evolution viaPOST /reputation/feedback. New files:trustConfigDb.ts,trustEvolutionDb.ts,trustEvolutionService.ts. -
2026-03-04 (Sprint 14 — Prestige Badges): Phase 1 prestige badges implemented (ADR-016). Migration 024 adds
reputation.badgestable. New service:badgeService.ts(checkAndAwardBadges,getUserBadges). Badges wired intomatch_completedhandler insubscriber.ts. New endpoint:GET /reputation/users/:userId/badges. Badge types:first_helper,milestone_10,milestone_50,milestone_100,connector(10+ distinct people helped). 11 unit tests intests/unit/reputation/prestige-badges.test.ts. -
2026-02-27 (Sprint 8 — ADR-040 + trust UX): Community Trust Score implemented (ADR-040). Bonding/bridging model:
member_quality(40) + bonding(retention+completion) + bridging(cross-community+external)weighted bycommunity_trust_bonding_weight/bridging_weightconfig. New files:communityTrustDb.ts,communityTrustService.ts. New endpoint:GET /reputation/community-trust/:communityId. New endpoint:GET /reputation/trust/:userId(overall weighted-average trust score). Migration 021. Trust page updated to ADR-037 formula display. Feed default changed to composite (no auto-select of first community). -
2026-02-27 (ADR-037 + ADR-038 + ADR-039): Multi-signal trust score fully implemented. Formula:
volume(log, 12-month window) + quality(recency-weighted, 6-month half-life) + depth(repeat pairs × depth_weight) + breadth(distinct people + communities × breadth_weight) + bonus. Karma removed as trust input. Cross-community carry floor (ADR-038): whenrecent_interactions == 0, score is floored bymin(carry_cap, floor(max_other_score × carry_factor)). Migrations 019 + 020 addtrust_feedback_threshold,trust_negative_allowed,trust_carry_*. New files:trustMetricsDb.ts,trustCarryDb.ts; new functionfeedbackDb.getWeightedAvgFeedback(). -
2026-02-26 (blended feedback):
feedbackDb.tsexportsgetBlendedAvgFeedback(toUserId, communityId)— 70% local + 30% cross-community blend. New composite DB indexidx_feedback_to_user_community(migration 018). -
2026-02-26 (trust formula): Interim interactions-primary model shipped. Superseded by ADR-037 on 2026-02-27.
-
2026-02-26 (bug fix): Fixed trust score read path in
karmaService.ts:getUserTrustScore(). Was reading non-existent columns (avg_helpfulness,avg_responsiveness,avg_clarity) fromreputation.trust_scores, causing feedback contribution to always be 0 on read. Now callsgetAvgFeedback()fromfeedbackDb.ts— same source as the POST feedback endpoint — so read and write paths are consistent. -
2026-02-25 (ADR-036): Private feedback ratings — star picker after match completion. Both parties rate each other via
POST /reputation/feedback. Feedback feeds into trust score viaavg_feedback_score. Ratings are never exposed via API. -
2026-02-25 (ADR-035): Karma now uses a fixed-pool model —
BASE_KARMA_POOLpoints divided across shared communities, preventing multi-community inflation. Trust score formula abstracted intotrustScoreStrategy.tsand now incorporates feedback ratings alongside karma. See ADR-035.
Purpose
Manages user karma points, trust scores, and badges within communities. Automatically awards karma when help exchanges are completed via a fixed-pool allocation strategy (ADR-035). Prevents gaming through milestone bonuses, trust score calculations, and community-scoped karma.
Database Schema
Tables Owned by This Service
-- reputation.karma_records
CREATE TABLE reputation.karma_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES communities.communities(id),
points INTEGER NOT NULL, -- Karma points awarded/deducted
reason VARCHAR(255) NOT NULL, -- 'Provided help', 'Received help', etc.
related_entity_id UUID, -- match_id or other reference
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- reputation.trust_scores
CREATE TABLE reputation.trust_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES communities.communities(id),
score INTEGER DEFAULT 0 NOT NULL, -- Trust score 0-100; 0 cold start (Sprint 126/ADR-096)
requests_completed INTEGER DEFAULT 0, -- Number of help requests completed
offers_accepted INTEGER DEFAULT 0, -- Number of times helped others
average_feedback NUMERIC(3,2) DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, community_id) -- One score per user per community
);
-- Sprint 126 / ADR-096: projection identity. Idempotent replay is a DATABASE guarantee — an
-- application-side SELECT-then-INSERT check cannot survive a crash in between. Partial, because
-- only rows attributable to a source entity have a projection identity at all; manual adjustments
-- carry a NULL related_entity_id and stay unconstrained.
CREATE UNIQUE INDEX uq_karma_match_projection
ON reputation.karma_records (user_id, community_id, reason, related_entity_id)
WHERE related_entity_id IS NOT NULL;
CREATE UNIQUE INDEX uq_activity_match_projection
ON reputation.activity_log (user_id, community_id, activity_type, related_entity_id)
WHERE related_entity_id IS NOT NULL;
-- trustMetricsDb self-joins karma_records on related_entity_id ("who else was awarded for this
-- match") to derive repeat pairs and distinct counterparties. Nothing led on that column, so every
-- trust-score computation scanned the table — on live match completions, not just during replay.
CREATE INDEX idx_karma_related_entity
ON reputation.karma_records (related_entity_id)
WHERE related_entity_id IS NOT NULL;
-- reputation.badges
CREATE TABLE reputation.badges (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
badge_type VARCHAR(100) NOT NULL, -- 'First Help', 'Milestone 10', etc.
badge_name VARCHAR(255) NOT NULL,
description TEXT,
earned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_karma_records_user_id ON reputation.karma_records(user_id);
CREATE INDEX idx_karma_records_community_id ON reputation.karma_records(community_id);
CREATE INDEX idx_trust_scores_user_community ON reputation.trust_scores(user_id, community_id);
-- feedback.feedback indexes (migration 018)
-- Single-column index for global avg scan already existed (idx_feedback_to_user)
-- Composite index for community-scoped getBlendedAvgFeedback local avg component
CREATE INDEX IF NOT EXISTS idx_feedback_to_user_community
ON feedback.feedback (to_user_id, community_id);
-- reputation.community_trust_scores new columns (migration 025 — ADR-045)
ALTER TABLE reputation.community_trust_scores
ADD COLUMN IF NOT EXISTS previous_score INTEGER,
ADD COLUMN IF NOT EXISTS previous_calculated_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS network_cohesion_score INTEGER,
ADD COLUMN IF NOT EXISTS network_reciprocity NUMERIC(5,4),
ADD COLUMN IF NOT EXISTS network_density NUMERIC(5,4),
ADD COLUMN IF NOT EXISTS network_clustering NUMERIC(5,4),
ADD COLUMN IF NOT EXISTS network_avg_path_length NUMERIC(6,4);
-- community_configs trust score extensions (migration 019 — ADR-037)
ALTER TABLE communities.community_configs
ADD COLUMN IF NOT EXISTS trust_feedback_threshold DECIMAL(3,1) DEFAULT 3.0
CHECK (trust_feedback_threshold BETWEEN 1.0 AND 4.9),
ADD COLUMN IF NOT EXISTS trust_negative_allowed BOOLEAN DEFAULT FALSE;
-- Note: trust_weights_sum constraint dropped (depth/breadth weights are independent)
-- community_configs carry model fields (migration 020 — ADR-038)
ALTER TABLE communities.community_configs
ADD COLUMN IF NOT EXISTS trust_carry_enabled BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS trust_carry_factor DECIMAL(3,2) DEFAULT 0.40,
ADD COLUMN IF NOT EXISTS trust_carry_cap INTEGER DEFAULT 59;
-- reputation.user_trust_configs (migration 026 — ADR-046)
-- Per-user trust parameters; NULL columns inherit community defaults.
CREATE TABLE reputation.user_trust_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES communities.communities(id) ON DELETE CASCADE,
depth_weight DECIMAL(4,3), -- NULL = use community default
breadth_weight DECIMAL(4,3), -- NULL = use community default
cross_community_prior DECIMAL(4,3), -- NULL = use community default
evolution_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, community_id)
);
-- reputation.user_trust_evolution_log (migration 026 — ADR-046)
-- Immutable append-only log of every parameter adjustment.
CREATE TABLE reputation.user_trust_evolution_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES communities.communities(id) ON DELETE CASCADE,
signal VARCHAR(100) NOT NULL, -- e.g. 'repeat_match', 'inline_feedback'
old_depth_weight DECIMAL(4,3),
new_depth_weight DECIMAL(4,3),
old_breadth_weight DECIMAL(4,3),
new_breadth_weight DECIMAL(4,3),
old_cross_community_prior DECIMAL(4,3),
new_cross_community_prior DECIMAL(4,3),
event_id UUID, -- match_id or feedback_id that triggered
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- communities.community_configs additions (migration 026 — ADR-046)
ALTER TABLE communities.community_configs
ADD COLUMN IF NOT EXISTS community_evolution_enabled BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS cross_community_prior DECIMAL(4,3) DEFAULT 0.15;
Social Karma v2.0 Schema Extensions:
-- Add interaction quality metrics to trust_scores
ALTER TABLE reputation.trust_scores
ADD COLUMN avg_helpfulness NUMERIC(3,2) DEFAULT 0,
ADD COLUMN avg_responsiveness NUMERIC(3,2) DEFAULT 0,
ADD COLUMN avg_clarity NUMERIC(3,2) DEFAULT 0,
ADD COLUMN total_feedback_received INTEGER DEFAULT 0;
-- reputation.community_health_metrics (NEW)
CREATE TABLE reputation.community_health_metrics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
community_id UUID NOT NULL REFERENCES communities.communities(id) ON DELETE CASCADE,
-- Snapshot date (daily aggregation)
snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE,
-- Network strength metrics
total_matches_completed INTEGER DEFAULT 0,
total_active_requesters INTEGER DEFAULT 0,
total_active_helpers INTEGER DEFAULT 0,
unique_participant_count INTEGER DEFAULT 0,
-- Interaction quality aggregates (community-wide averages)
avg_helpfulness NUMERIC(3,2) DEFAULT 0,
avg_responsiveness NUMERIC(3,2) DEFAULT 0,
avg_clarity NUMERIC(3,2) DEFAULT 0,
-- Network density (connections per member)
network_density NUMERIC(5,4) DEFAULT 0,
-- Growth metrics (vs previous period)
growth_rate_matches NUMERIC(5,2) DEFAULT 0,
growth_rate_participants NUMERIC(5,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(community_id, snapshot_date)
);
CREATE INDEX idx_health_metrics_community ON reputation.community_health_metrics(community_id);
CREATE INDEX idx_health_metrics_date ON reputation.community_health_metrics(snapshot_date);
-- reputation.milestone_events (NEW)
CREATE TABLE reputation.milestone_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
community_id UUID NOT NULL REFERENCES communities.communities(id) ON DELETE CASCADE,
-- Milestone type
milestone_type VARCHAR(100) NOT NULL,
milestone_value INTEGER NOT NULL,
description TEXT NOT NULL,
-- Privacy control
is_featured BOOLEAN DEFAULT true,
achieved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_milestone_events_community ON reputation.milestone_events(community_id);
CREATE INDEX idx_milestone_events_type ON reputation.milestone_events(milestone_type);
Social Karma v2.0 Design Principles:
- Metrics Over Individuals: Focus on community health metrics, not individual rankings
- Collective Prestige: Track community-level achievements and growth
- Interaction Quality: Incorporate feedback ratings into trust scores
- Growth Visibility: Show network strength trends and milestone achievements
Tables Read by This Service
auth.users- User names for leaderboardscommunities.communities- Community names for karma historyrequests.help_requests- Get community_id from request when match completed
Karma Points Configuration (ADR-035)
Karma is awarded via a fixed-pool model — a total of BASE_KARMA_POOL points (default: 100) is allocated per interaction, divided across all shared communities. This prevents inflation for users in many communities.
Pool distribution:
- Total pool is split equally across shared communities (up to
MAX_COMMUNITIES_PER_KARMA_AWARD = 3) - Each community applies its configured helper/requester split ratio to its share
- Largest-remainder rounding ensures integer awards sum exactly to the pool
Per-community split (community-configurable):
| Role | Default split | Description |
|---|---|---|
| Helper (responder) | 60% of community share | Awarded for providing help |
| Requester | 40% of community share | Awarded for receiving help |
Bonus awards (fixed, not from pool):
| Milestone | Points | Description |
|---|---|---|
| First Help Bonus | 15 | First time helping in a community |
| 10 Exchanges | 25 | Completing 10 help exchanges |
| 50 Exchanges | 50 | Completing 50 help exchanges |
| 100 Exchanges | 100 | Completing 100 help exchanges |
Tuning surface (Sprint 126 / ADR-096): @karmyq/shared src/projections/completedMatchStanding.ts — allocateCompletedMatchKarma(configs, totalPool, requestType?). The reputation-service karmaAllocation.ts shim was deleted once its importers moved; live delivery, the curated fixture, and historical replay now share one implementation.
Request type multipliers: Per-community multipliers read from CommunityKarmaConfig.enabled_request_types[].karma_multiplier. Default: 1.0 (no change). Applied per community before distribution.
Configuration defaults: @karmyq/shared — COMPLETED_MATCH_REASONS, COMPLETED_MATCH_MILESTONES, MAX_COMMUNITIES_PER_KARMA_AWARD, DEFAULT_KARMA_POOL. The service-local KARMA_DEFAULTS block was removed in Sprint 126; it duplicated these byte-for-byte and nothing could fail if the two drifted.
Trust Score Calculation
Implemented per ADR-037 + ADR-039 + ADR-038. Karma is not a trust input.
Formula (ADR-037 + ADR-039)
Trust score ranges from floor (0 or -50) to 100. New users start at 0:
volume_score = min(30, floor(log2(recent_interactions + 1) × 10))
recent_interactions = completed interactions in last 12 months (ADR-039)
quality_score = avg_feedback_score != null
? round(((avg_feedback_score - threshold) / (5 - threshold)) × 25)
: 0
avg_feedback_score = recency-weighted blend (6-month half-life, ADR-039)
threshold = community_configs.trust_feedback_threshold (default 3.0)
depth_score = min(15, repeat_interaction_pairs × 2) × trust_depth_weight
breadth_score = (min(10, distinct_people × 2) + min(10, distinct_communities × 3))
× trust_breadth_weight
bonus_score = recent_interactions >= min_interactions_for_trust ? 5 : 0
floor = trust_negative_allowed ? -50 : 0
trust_score = max(floor, min(100, round(raw_score)))
Tier thresholds: New (0–19), Active (20–49), Trusted (50–74), Highly Trusted (75–100).
Cross-community carry floor (ADR-038)
When recent_interactions == 0, score is floored by:
carried = min(carry_cap, floor(max_other_community_score × carry_factor))
score = max(local_score, carried)
Defaults: carry_factor = 0.40, carry_cap = 59. Configurable via trust_carry_factor/cap/enabled.
Community configuration fields
| Field | Default | Effect |
|---|---|---|
trust_depth_weight | 0.6 | Bonding capital weight (repeat pairs) |
trust_breadth_weight | 0.4 | Bridging capital weight (distinct people + communities) |
trust_feedback_threshold | 3.0 | Neutral star rating (above = positive, below = negative) |
trust_negative_allowed | false | Allow scores below 0 (punitive mode) |
trust_carry_enabled | true | Cross-community carry floor |
trust_carry_factor | 0.40 | Fraction of best other score to carry |
trust_carry_cap | 59 | Max carried score |
Tuning surface: src/services/trustScoreStrategy.ts — computeTrustScore(inputs: TrustScoreInputs)
Feedback weighting: src/database/feedbackDb.ts — pure calculateWeightedAvgFeedback(rows, communityId, ...), used by both getWeightedAvgFeedback(userId, communityId, halfLifeMonths=6) and standing preflight
Depth/breadth metrics: src/database/trustMetricsDb.ts — getTrustMetrics(userId, communityId)
Carry floor: src/database/trustCarryDb.ts — getMaxOtherCommunityScore(userId, targetCommunityId)
API Endpoints
GET /reputation/karma/:userId
Get user's total karma across all communities.
Query Parameters:
community_id- Filter by specific community (optional)
Response:
{
"success": true,
"data": {
"user_id": "uuid",
"total_karma": 145,
"by_community": [
{
"community_id": "uuid",
"total_karma": "100",
"transaction_count": "12"
},
{
"community_id": "uuid",
"total_karma": "45",
"transaction_count": "5"
}
]
}
}
Implementation: src/routes/reputation.ts:8
GET /reputation/trust/:userId
Get user's overall trust score — weighted average across all communities, weighted by recent interaction count.
Response:
{
"success": true,
"data": {
"overall_score": 68,
"community_breakdown": [
{ "community_id": "uuid", "community_name": "Neighborly", "score": 74, "recent_interactions": 5 },
{ "community_id": "uuid", "community_name": "Tech Help", "score": 61, "recent_interactions": 2 }
]
}
}
Implementation: src/routes/reputation.ts | src/services/karmaService.ts:getOverallTrustScore()
GET /reputation/trust/:userId/:communityId
Get user's trust score in a specific community.
Response:
{
"success": true,
"data": {
"user_id": "uuid",
"community_id": "uuid",
"score": 75,
"requests_completed": 8,
"offers_accepted": 12,
"average_feedback": 4.5,
"last_updated": "2025-01-10T12:00:00Z"
}
}
Implementation: src/routes/reputation.ts:37
Note: Returns default score of 50 if user has no trust score yet.
GET /reputation/leaderboard/:communityId
Get top karma earners in a community.
Query Parameters:
limit- Max results (default: 10)
Response:
{
"success": true,
"data": [
{
"user_id": "uuid",
"name": "Alice Smith",
"total_karma": "250",
"trust_score": 90,
"requests_completed": 15,
"offers_accepted": 20
},
{
"user_id": "uuid",
"name": "Bob Johnson",
"total_karma": "180",
"trust_score": 80,
"requests_completed": 10,
"offers_accepted": 15
}
]
}
Implementation: src/routes/reputation.ts:58
Note: Ordered by total_karma DESC, limited to top N users.
GET /reputation/history/:userId
Get karma transaction history for a user.
Query Parameters:
community_id- Filter by community (optional)limit- Max results (default: 50)offset- Pagination offset (default: 0)
Response:
{
"success": true,
"data": [
{
"id": "uuid",
"points": 10,
"reason": "Provided help",
"related_entity_id": "match-uuid",
"created_at": "2025-01-10T12:00:00Z",
"community_id": "uuid",
"community_name": "Seattle Mutual Aid"
},
{
"id": "uuid",
"points": 15,
"reason": "First help in community",
"related_entity_id": "match-uuid",
"created_at": "2025-01-10T12:00:00Z",
"community_id": "uuid",
"community_name": "Seattle Mutual Aid"
}
],
"count": 2
}
Implementation: src/routes/reputation.ts:80
GET /reputation/users/:userId/badges
Get all prestige badges earned by a user (public). Phase 1 badge types: first_helper, milestone_10, milestone_50, milestone_100, connector.
Response:
{
"success": true,
"data": [
{
"id": "uuid",
"user_id": "uuid",
"community_id": null,
"badge_type": "first_helper",
"earned_at": "2026-03-04T12:00:00Z"
}
]
}
Implementation: src/routes/reputation.ts:129
Social Karma v2.0 API Endpoints
GET /reputation/community-health/:communityId
Get community health metrics and trends.
Query Parameters:
period- Time period for trend calculation ('7d', '30d', '90d', default: '7d')
Response:
{
"success": true,
"data": {
"current": {
"total_matches_completed": 127,
"unique_participants": 45,
"total_active_requesters": 28,
"total_active_helpers": 35,
"avg_helpfulness": 4.6,
"avg_responsiveness": 4.8,
"avg_clarity": 4.5,
"network_density": 0.342,
"network_strength": 78.5
},
"trend": {
"matches_growth": 15.2,
"participants_growth": 8.5,
"direction": "growing"
},
"period": "7d",
"snapshot_date": "2025-01-13"
}
}
Implementation: src/routes/health.ts (NEW)
Network Strength Calculation:
network_strength = weighted_average(
activity_score * 0.4, // matches per member
quality_score * 0.4, // avg interaction ratings
density_score * 0.2 // connection diversity
)
GET /reputation/milestones/:communityId
Get community milestone achievements.
Query Parameters:
limit- Max results (default: 10)
Response:
{
"success": true,
"data": [
{
"id": "uuid",
"milestone_type": "100_matches",
"milestone_value": 100,
"description": "Reached 100 successful help exchanges!",
"is_featured": true,
"achieved_at": "2025-01-10T12:00:00Z"
},
{
"id": "uuid",
"milestone_type": "50_participants",
"milestone_value": 50,
"description": "50 unique members have participated in help exchanges",
"is_featured": true,
"achieved_at": "2025-01-08T09:30:00Z"
}
],
"count": 2
}
Implementation: src/routes/health.ts (NEW)
Milestone Types:
10_matches,50_matches,100_matches,500_matches,1000_matches10_participants,25_participants,50_participants,100_participantsavg_quality_4.5- Average quality rating reaches 4.5+
GET /reputation/trust/:userId/:communityId
Enhanced trust score with interaction quality (UPDATED).
Response:
{
"success": true,
"data": {
"user_id": "uuid",
"community_id": "uuid",
"score": 75,
"requests_completed": 8,
"offers_accepted": 12,
"average_feedback": 4.5,
"interaction_quality": {
"avg_helpfulness": 4.6,
"avg_responsiveness": 4.8,
"avg_clarity": 4.5,
"total_feedback_received": 12
},
"last_updated": "2025-01-10T12:00:00Z"
}
}
Implementation: src/routes/reputation.ts:37 (UPDATED)
Note: Now includes detailed interaction quality metrics from Social Karma v2.0 feedback system.
GET /reputation/network-metrics/:communityId
Get the network cohesion score for a community (ADR-045). Four graph topology metrics — reciprocity, density, clustering coefficient, and path score — over a rolling 90-day window.
Response:
{
"success": true,
"data": {
"community_id": "uuid",
"network_cohesion_score": 67,
"label": "Cohesive",
"reciprocity": 0.42,
"density": 0.18,
"clustering": 0.61,
"avg_path_length": 2.3,
"active_member_count": 22,
"window_days": 90,
"last_calculated": "2026-03-10T..."
}
}
Labels: ≥80 "Highly Cohesive", ≥60 "Cohesive", ≥40 "Developing", ≥20 "Emerging", <20 "Fragile"
Implementation: src/routes/reputation.ts | src/services/networkCohesionService.ts:calculateNetworkCohesion()
GET /reputation/community-trust/:communityId
Get the community trust score (ADR-040). Computed daily; recalculates on demand if no score exists yet.
Query params: ?recalculate=true forces a fresh calculation.
Response:
{
"success": true,
"data": {
"community_id": "uuid",
"score": 72,
"member_quality_score": 30,
"bonding_score": 25,
"bridging_score": 17,
"active_member_count": 14,
"last_calculated": "2026-02-27T..."
}
}
Access (ADR-082): the caller must be an active member of a community with at least 5 active
members. Otherwise, and also when no score can be computed, the response is
200 { "success": true, "data": null }, identical for every cause (Sprint 129, BUG-031). Clients
treat data === null as "no aggregate to show", never as an error.
Implementation: src/routes/reputation.ts | src/services/communityTrustService.ts:calculateCommunityTrustScore()
POST /reputation/feedback (Authenticated)
Submit a private quality rating after a completed interaction. Ratings are internal trust signals — never exposed to users (ADR-036).
Request Body:
{
"match_id": "uuid",
"to_user_id": "uuid",
"community_id": "uuid",
"rating": 4
}
rating: integer 1–5- Prevents duplicate submission for the same match
Response:
{
"success": true,
"data": { "score": 72 }
}
Returns the updated trust score for the rated user.
Side effects: Calls updateTrustScore(to_user_id, community_id) — full ADR-037 multi-signal recomputation (volume + quality + depth + breadth + bonus with ADR-039 time-weighting). Returns the new score.
GET /health
Service health check.
Response:
{
"service": "reputation-service",
"status": "healthy",
"timestamp": "2025-01-10T12:00:00Z"
}
Trust Evolution API Endpoints (Sprint 30 — ADR-046)
GET /reputation/trust-config/:userId/:communityId
Get user trust config and effective parameters (merged community defaults + per-user overrides).
Response:
{
"success": true,
"data": {
"user_id": "uuid",
"community_id": "uuid",
"evolution_enabled": true,
"effective_params": {
"depth_weight": 0.62,
"breadth_weight": 0.38,
"cross_community_prior": 0.15
},
"source": "user_override | community_default"
}
}
Implementation: src/routes/reputation.ts | src/database/trustConfigDb.ts
PUT /reputation/trust-config/:userId/:communityId
Toggle evolution_enabled for a user in a community (user or admin).
Request Body:
{ "evolution_enabled": true }
Response:
{
"success": true,
"data": { "evolution_enabled": true }
}
Implementation: src/routes/reputation.ts | src/database/trustConfigDb.ts
GET /reputation/trust-config/:userId/:communityId/history
Return the immutable evolution log for a user — every parameter adjustment with trigger signal and event ID.
Response:
{
"success": true,
"data": [
{
"id": "uuid",
"user_id": "uuid",
"community_id": "uuid",
"signal": "repeat_match",
"old_depth_weight": 0.60,
"new_depth_weight": 0.62,
"event_id": "match-uuid",
"created_at": "2026-03-19T..."
}
]
}
Implementation: src/routes/reputation.ts | src/database/trustEvolutionDb.ts
GET /reputation/communities/:communityId/trust-evolution
Get community-level evolution status (admin only). Returns whether evolution is enabled and the community cross_community_prior.
Response:
{
"success": true,
"data": {
"community_id": "uuid",
"community_evolution_enabled": true,
"cross_community_prior": 0.15
}
}
Implementation: src/routes/reputation.ts
PUT /reputation/communities/:communityId/trust-evolution
Toggle community evolution on/off (admin only).
Request Body:
{ "community_evolution_enabled": true }
Response:
{
"success": true,
"data": { "community_evolution_enabled": true }
}
Implementation: src/routes/reputation.ts
Event-Driven Architecture
The reputation service automatically awards karma by listening to events from other services.
Events Consumed
match_completed - Triggers karma award
When a match is completed, the reputation service:
- Re-reads the match's authoritative facts under
pg_advisory_xact_lock(the payload is a message, not a record; a payload disagreeing with stored participants/status is rejected) - Finds shared communities (request communities ∩ both users' active memberships), ranked by the helper's karma from history strictly before this match, capped at 3
- Calls the canonical
planCompletedMatchStanding()in@karmyq/sharedfor every row the match produces — helper award, requester award, and at most one milestone bonus per community - Writes them all in ONE transaction with
ON CONFLICT DO NOTHING, stamped with the match's storedcompleted_at(neverNOW()) - Refreshes trust scores for both users via
updateTrustScore()— the deliberate present-state exception, run after every as-of decision
Event Handler: src/events/subscriber.ts
Standing projection: src/services/standingProjector.ts (Sprint 126 / ADR-096). The former
karmaService.awardKarmaForCompletedMatch and allocateKarma were deleted — see BUG-037 above
for why that path had been throwing 42703 on every match since Sprint 62.
Event Payload:
{
"event": "match_completed",
"payload": {
"match_id": "uuid",
"request_id": "uuid",
"requester_id": "uuid",
"responder_id": "uuid"
}
}
Dependencies
Calls (Outbound)
- Request Service (via database) - Get community_id from request when match completed
Called By (Inbound)
- Frontend (to display karma, trust scores, leaderboards)
- Feed Service (to get user reputation for feed explanations)
Events Published
milestone_achieved- When community reaches a milestone (Social Karma v2.0)health_metrics_calculated- Daily metric calculation complete (Social Karma v2.0)provider_review_received- After a provider review is saved and trust score recalculated (Sprint 37); consumed by notification-service to alert the provider
Events Consumed
match_completed- Award karma when help exchange completed; also recalculatescompletion_rateonreputation.provider_trust_scoresif responder has an active provider profile (cross-schema:requests.provider_profiles)interaction_feedback_submitted- Update trust scores with interaction quality (Social Karma v2.0)
External Dependencies
- PostgreSQL (reputation schema)
- Redis (event subscription via Bull queue)
Environment Variables
# Server
PORT=3004
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/karmyq_db
# Redis
REDIS_URL=redis://localhost:6379
# Logging
LOG_LEVEL=info # debug, info, warn, error
Key Files
Entry Point
src/index.ts- Express app initialization, event subscriber setup
Routes
src/routes/reputation.ts- Karma, trust score, leaderboard, badges endpointssrc/routes/health.ts- Community health metrics and milestones (Social Karma v2.0) - NEW
Services
src/services/karmaService.ts- Karma award logic, trust score calculation,getOverallTrustScore()(weighted average across communities)src/services/communityTrustService.ts- Community Trust Score (ADR-040): bonding/bridging formula,calculateCommunityTrustScore(),calculateAllCommunityTrustScores()src/services/healthMetricsService.ts- Community health calculation (Social Karma v2.0); also runs community trust calculation dailysrc/services/milestoneDetector.ts- Milestone detection logic (Social Karma v2.0) - NEW
Events
src/events/subscriber.ts- Listens to match_completed and interaction_feedback_submitted events
Background Jobs
src/cron/healthMetricsCalculator.ts- Daily health metrics aggregation (Social Karma v2.0) - NEW
Database
src/database/db.ts- PostgreSQL connection poolsrc/database/feedbackDb.ts-getAvgFeedback(userId)(global),getBlendedAvgFeedback(userId, communityId)(70/30 blend),getWeightedAvgFeedback(userId, communityId, halfLifeMonths=6)(recency-weighted, used in trust score path)src/database/trustMetricsDb.ts-getTrustMetrics(userId, communityId)— repeat pairs, distinct people, distinct communities (ADR-037)src/database/trustCarryDb.ts-getMaxOtherCommunityScore(userId, targetCommunityId)— carry floor query (ADR-038)src/database/communityTrustDb.ts-getCommunityTrustScore(communityId),upsertCommunityTrustScore()— ADR-040 community trust persistence
Common Development Tasks
Change Karma Point Values
Edit karma configuration:
// src/services/karmaService.ts
const KARMA_CONFIG = {
HELP_PROVIDED: 15, // Changed from 10
HELP_RECEIVED: 10, // Changed from 5
FIRST_HELP: 20, // Changed from 15
MILESTONE_10: 30, // Changed from 25
MILESTONE_50: 75, // Changed from 50
MILESTONE_100: 150, // Changed from 100
};
Add New Karma Reason
- Add to karma award logic:
// src/services/karmaService.ts
export async function awardKarmaForNewReason(data: any) {
await recordKarma({
user_id: data.user_id,
community_id: data.community_id,
points: 5,
reason: 'New reason description',
related_entity_id: data.entity_id,
});
// Update trust score
await updateTrustScore(data.user_id, data.community_id);
}
- Subscribe to new event:
// src/events/subscriber.ts
eventQueue.process('new_event_name', async (job) => {
const { payload } = job.data;
await awardKarmaForNewReason(payload);
});
Add New Milestone
Milestones are policy, not service code, and live in one place:
packages/shared/src/projections/completedMatchStanding.ts.
export const COMPLETED_MATCH_MILESTONES = [
{ count: 1, points: 15, reason: COMPLETED_MATCH_REASONS.first },
{ count: 10, points: 25, reason: COMPLETED_MATCH_REASONS.milestone10 },
{ count: 50, points: 50, reason: COMPLETED_MATCH_REASONS.milestone50 },
{ count: 100, points: 100, reason: COMPLETED_MATCH_REASONS.milestone100 },
{ count: 250, points: 250, reason: COMPLETED_MATCH_REASONS.milestone250 }, // new
];
Add the matching label to COMPLETED_MATCH_REASONS first — the reason strings are a SQL data
contract, and updateTrustScore compares against them literally. Adding it here applies it to live
delivery, the curated fixture, and historical replay at once, which is the point of the shared
policy.
Change Trust Score Algorithm
// src/services/karmaService.ts - In updateTrustScore
// Current algorithm:
const karma_contribution = Math.min(50, Math.floor(total_karma / 10));
const score = 50 + karma_contribution;
// Alternative: Logarithmic scaling
const karma_contribution = Math.min(50, Math.floor(Math.log10(total_karma + 1) * 20));
const score = 50 + karma_contribution;
// Alternative: Exponential diminishing returns
const karma_contribution = Math.min(50, Math.floor(50 * (1 - Math.exp(-total_karma / 500))));
const score = 50 + karma_contribution;
Add Feedback/Rating System
- Create feedback table:
-- infrastructure/postgres/migrations/00X_add_feedback.sql
CREATE TABLE reputation.match_feedback (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
match_id UUID NOT NULL REFERENCES requests.matches(id),
from_user_id UUID NOT NULL REFERENCES auth.users(id),
to_user_id UUID NOT NULL REFERENCES auth.users(id),
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(match_id, from_user_id)
);
- Add feedback endpoint:
// src/routes/reputation.ts
router.post('/feedback', async (req, res) => {
const { match_id, from_user_id, to_user_id, rating, comment } = req.body;
// Validate match exists and user was part of it
const match = await query(
`SELECT requester_id, responder_id FROM requests.matches
WHERE id = $1`,
[match_id]
);
if (!match.rows[0]) {
return res.status(404).json({ success: false, message: 'Match not found' });
}
const { requester_id, responder_id } = match.rows[0];
if (from_user_id !== requester_id && from_user_id !== responder_id) {
return res.status(403).json({ success: false, message: 'Not part of this match' });
}
// Record feedback
await query(
`INSERT INTO reputation.match_feedback
(match_id, from_user_id, to_user_id, rating, comment)
VALUES ($1, $2, $3, $4, $5)`,
[match_id, from_user_id, to_user_id, rating, comment]
);
// Update trust score with new average_feedback
await updateTrustScoreWithFeedback(to_user_id, community_id);
res.json({ success: true, message: 'Feedback recorded' });
});
- Update trust score calculation:
// src/services/karmaService.ts
async function updateTrustScoreWithFeedback(user_id: string, community_id: string) {
// Get average rating
const feedback = await query(
`SELECT AVG(rating) as avg_rating
FROM reputation.match_feedback
WHERE to_user_id = $1`,
[user_id]
);
const average_feedback = parseFloat(feedback.rows[0].avg_rating || 0);
// Include in trust score calculation
const feedback_bonus = Math.floor((average_feedback - 3) * 5); // -10 to +10
const score = 50 + karma_contribution + feedback_bonus;
}
Implement Badge System
// src/services/badgeService.ts
export async function checkAndAwardBadges(user_id: string, community_id: string) {
const karma = await getUserKarma(user_id, community_id);
const total_karma = parseInt(karma[0]?.total_karma || 0);
// Helper badge (10 helps)
const helperCount = await query(
`SELECT COUNT(*) as count FROM reputation.karma_records
WHERE user_id = $1 AND community_id = $2 AND reason = 'Provided help'`,
[user_id, community_id]
);
if (parseInt(helperCount.rows[0].count) === 10) {
await awardBadge({
user_id,
badge_type: 'helper',
badge_name: 'Community Helper',
description: 'Helped 10 people in the community',
});
}
// Karma milestones
if (total_karma >= 100) {
await awardBadge({
user_id,
badge_type: 'karma_milestone',
badge_name: 'Karma Master',
description: 'Earned 100+ karma points',
});
}
}
async function awardBadge(data: any) {
// Check if badge already awarded
const existing = await query(
`SELECT id FROM reputation.badges
WHERE user_id = $1 AND badge_type = $2`,
[data.user_id, data.badge_type]
);
if (existing.rowCount > 0) return;
// Award badge
await query(
`INSERT INTO reputation.badges
(user_id, badge_type, badge_name, description)
VALUES ($1, $2, $3, $4)`,
[data.user_id, data.badge_type, data.badge_name, data.description]
);
// Publish event
await publishEvent('badge_earned', {
user_id: data.user_id,
badge_type: data.badge_type,
});
}
Security Considerations
Event-Driven Karma Awards
- Karma can only be awarded through events (not via API)
- Prevents users from manually awarding themselves karma
- All karma awards are auditable in karma_records table
Automatic Calculation
- Trust scores calculated automatically
- No manual override via API
- Prevents gaming the system
Milestone Detection
- First help bonus only awarded once per community
- Milestone bonuses only awarded at exact count (10, 50, 100)
- Uses COUNT from karma_records to detect milestones
// src/services/karmaService.ts
const helperHistory = await query(
`SELECT COUNT(*) as count FROM reputation.karma_records
WHERE user_id = $1 AND community_id = $2 AND reason = 'Provided help'`,
[responder_id, community_id]
);
if (parseInt(helperHistory.rows[0].count) === 1) {
// Only award first help bonus if count is exactly 1
await recordKarma({...});
}
Audit Trail
- Every karma transaction recorded with reason and timestamp
- related_entity_id links to match/event that triggered it
- Full history available via /reputation/history endpoint
Debugging Common Issues
Karma not being awarded
- Check event queue is running:
redis-cli LLEN karmyq-events - Check event subscriber logs for errors
- Verify match_completed event was published: Check request-service logs
- Check karma_records table:
SELECT * FROM reputation.karma_records WHERE user_id = '...' ORDER BY created_at DESC LIMIT 5 - Look for error logs in reputation service
Trust score not updating
- Check trust_scores table:
SELECT * FROM reputation.trust_scores WHERE user_id = '...' AND community_id = '...' - Verify karma_records exist for user in community
- Check updateTrustScore was called (logs should show "Karma awarded")
- Recalculate manually:
-- Check total karma
SELECT SUM(points) FROM reputation.karma_records WHERE user_id = '...' AND community_id = '...';
-- Manually trigger update (via API)
-- Award any karma and it will recalculate
Leaderboard empty or incorrect
- Check karma_records exist:
SELECT COUNT(*) FROM reputation.karma_records WHERE community_id = '...' - Verify trust_scores exist:
SELECT COUNT(*) FROM reputation.trust_scores WHERE community_id = '...' - Check JOIN is working: Run leaderboard query manually in psql
- Verify community_id is correct
Milestone bonus not awarded
- Check exact count:
SELECT COUNT(*) FROM reputation.karma_records WHERE user_id = '...' AND community_id = '...' AND reason = 'Provided help' - Verify milestone only triggers at exact count (10, 50, 100)
- Check if milestone was already awarded:
SELECT * FROM reputation.karma_records WHERE reason LIKE '%milestone%' AND user_id = '...'
Redis connection errors
- Check REDIS_URL is correct
- Verify Redis is running:
redis-cli -u $REDIS_URL ping - Check event subscriber initialization in logs
- Test queue connection:
redis-cli LLEN karmyq-events
Testing
Manual Testing with curl
Get User Karma:
curl "http://localhost:3004/reputation/karma/uuid-here"
Get User Karma in Specific Community:
curl "http://localhost:3004/reputation/karma/uuid-here?community_id=community-uuid"
Get Trust Score:
curl "http://localhost:3004/reputation/trust/user-uuid/community-uuid"
Get Leaderboard:
curl "http://localhost:3004/reputation/leaderboard/community-uuid?limit=20"
Get Karma History:
curl "http://localhost:3004/reputation/history/user-uuid?limit=10"
Simulate Match Completion (triggers karma award):
# Use Redis CLI to publish event
redis-cli LPUSH karmyq-events '{"event":"match_completed","payload":{"match_id":"uuid","request_id":"uuid","requester_id":"uuid","responder_id":"uuid"}}'
Unit Tests
Run tests:
npm test
Test structure:
src/
├── __tests__/
│ ├── karma.test.ts # Karma award logic tests
│ ├── trustScore.test.ts # Trust score calculation tests
│ └── events.test.ts # Event subscription tests
Performance Considerations
- Karma award logic runs in background queue (doesn't block match completion)
- Trust score updates use UPSERT (ON CONFLICT) for efficiency
- Leaderboard query uses LEFT JOIN and GROUP BY (indexed on community_id)
- Connection pooling for PostgreSQL (max 20 connections)
- Event queue processes one match_completed at a time (prevents race conditions)
Future Enhancements (TODO)
-
Feedback/rating system — private star ratings, ADR-036 (implemented)
-
Decay factor for old karma — 6-month half-life, ADR-011 (implemented)
-
Advanced trust score algorithm — ADR-037 multi-signal formula (implemented)
-
Reputation portability across communities — ADR-038 carry model (implemented)
-
Provider trust score — ADR-042 (stars 60% + completion 30% + response 10%), implemented 2026-02-27
POST /reputation/provider-reviews— submit review (auth required)GET /reputation/provider-trust/:providerId— trust score withdisplay_name,service_typeand owneruser_id. AUTHENTICATION REQUIRED since Sprint 125 / ADR-095 (was public)GET /reputation/provider-reviews/:providerId— review list including review text andreviewer_name. AUTHENTICATION REQUIRED since Sprint 125 / ADR-095 (was public)
⚠️ These two were closed one review-round after the three request-service provider routes. Closing only those left the provider directory anonymously enumerable through this service, and
provider-reviewswas the most sensitive of the five — it returned the real names of members who left reviews. When auditing an access surface, enumerate by data exposed, not by service.- New tables:
reputation.provider_reviews,reputation.provider_trust_scores
-
Negative karma for reported issues
-
Badge system implementation
-
Karma leaderboard across all communities
-
Reputation-based privileges (verified helpers, trusted requesters)
-
Federation support (federated reputation scores)
Community Evolution API Endpoints (Sprint 31 — ADR-047)
GET /reputation/community/:communityId/evolution/history
Admin — paginated community evolution log showing parameter changes over time.
Implementation: src/routes/reputation.ts | src/database/communityEvolutionDb.ts
GET /reputation/community/:communityId/evolution/summary
Admin — drift summary: first evolution date, evolved parameter count, last contributing member count.
Implementation: src/routes/reputation.ts | src/database/communityEvolutionDb.ts
PUT /reputation/community/:communityId/evolution/toggle
Admin — enable/disable community evolution engine. Body: { "enabled": boolean }.
Implementation: src/routes/reputation.ts | src/database/trustEvolutionDb.ts
Fractal Feed API Endpoints (Sprint 32 — ADR-046 complete)
GET /reputation/users/:userId/effective-params?communityId=
Returns blended trust params from Redis cache (4h TTL), falls back to DB on cache miss. Auth: self only.
| Field | Type | Description |
|---|---|---|
| depth_weight | number | Evolved depth weight (or community default if not evolved) |
| breadth_weight | number | Evolved breadth weight (or community default if not evolved) |
| cross_community_prior | number | Evolved cross-community prior |
Implementation: src/routes/reputation.ts | src/services/effectiveParamsCache.ts
GET /reputation/users/:userId/evolution-global
Returns the user's global evolution opt-in preference. Missing row = true (default opt-in). Auth: self only.
Response: { success: true, data: { global_evolution_enabled: boolean } }
Implementation: src/routes/reputation.ts | src/database/trustEvolutionDb.ts
PUT /reputation/users/:userId/evolution-global
Set global evolution enabled/disabled for the user. Body: { "global_evolution_enabled": boolean }. Auth: self only.
Implementation: src/routes/reputation.ts | src/database/trustEvolutionDb.ts
Related Documentation
- Main architecture:
/docs/ARCHITECTURE.md - Database schema:
/infrastructure/postgres/init.sql(lines 140-181) - Karma configuration:
src/services/karmaService.ts:11-18 - Federation reputation:
/docs/FEDERATION_PROTOCOL.md(section: Federated Reputation)
Sprint 112 — Reputation Disclosure Boundary (ADR-082, 2026-06-24)
Exact ordinary-member reputation is now self-only at the API boundary. Math (ADR-037/038/039, ADR-011 decay) is unchanged; only authorization, projection, and naming changed.
- New:
GET /reputation/me/community-summary?community_id=→ canonicalSelfCommunityReputation(scope + 0–100 reputation score/tier + decayed karma/trend + 30d activity). Self-only + active membership;400 INVALID_COMMUNITY_ID,404 REPUTATION_NOT_FOUND. Built inutils/disclosureAuth.ts. - Self-only (cross-user →
404 REPUTATION_NOT_FOUND, no admin exception):/karma/:userId,/trust/:userId,/trust/:userId/:communityId,/history/:userId,/badges/:userId,/users/:userId/badges,/trust-config/:userId/:communityId[/history](GET/PUT),/users/:userId/effective-params,/users/:userId/evolution-global(GET/PUT). - Community aggregates (active member + ≥5 cohort, else
404 AGGREGATE_NOT_AVAILABLE):/community-trust/:communityId,/community-health/:communityId,/milestones/:communityId,/network-metrics/:communityId(utils/disclosureAuth.checkAggregateAccess). - Retired:
GET /reputation/leaderboard/:communityId→410 REPUTATION_LEADERBOARD_RETIRED(internalgetCommunityLeaderboard()kept). - Disclosure classifications live in
services/registry.json#reputation_disclosure+tests/fixtures/reputation-disclosure-inventory.json, gated bytests/regression/reputation-disclosure-gate.test.ts.
Sprint 113 (PR A) — client consumption of the boundary
No API change. The frontend was reconciled onto the contract above (defense in depth — ADR-082 UI
section): the member's own profile (profile.tsx) now reads only /me/community-summary
instead of recombining getMyKarma + getTrustScore (the dual-source mismatch behind BUG-024/026),
and governance/stewardship surfaces no longer render NaN for the omitted member trust_score/karma
(BUG-025). The per-member getTrustScore reads on the community member-topology surface are cross-user
and correctly receive 404 REPUTATION_NOT_FOUND.
Sprint 122 — Express 5 (2026-07-29)
@types/express 4.17.21 → 5.0.6. Express 5's path-to-regexp 8 widened route params to
string | string[] (a repeatable :ids+ or wildcard *splat segment captures an array), which
surfaced as TS2345 at every req.params read. Karmyq declares no such segment, so params are
narrowed back to string via RouteParams (exported from @karmyq/shared/middleware/auth)
rather than widened with as any. The invariant is enforced by
tests/regression/sprint-122-express5-route-params.test.ts, which fails if any route literal
introduces wildcard or repeatable syntax.
Changed: src/routes/health.ts — 3 handlers (/community-health/:communityId,
/milestones/:communityId, /network-metrics/:communityId) annotated Request<RouteParams>.
The remaining routes in this service take AuthenticatedRequest and inherit the fix.
Express 4.18.2 → 5.2.1, supplied by the root package.json production dependency
(the Dockerfiles copy the root manifest and npm install --omit=dev). No endpoint, payload,
status code or event contract changed — feedback:check flags this service's src/routes/
diff as a "route change", but the diff is type annotations only, so the API Endpoints section
above is still accurate.
Express 5 semantics now in force: async handler rejections auto-forward to the error middleware,
res.status() throws RangeError on an out-of-range code, and req.query is a getter rather
than a writable own property.
⚠️ req.body default restored (the bug this PR actually shipped to CI). body-parser 1
initialised req.body to {} on every request; body-parser 2 leaves it undefined unless a
body was parsed, so const { x } = req.body throws a TypeError on a bodyless request and the
route's catch turns it into a 500. app.use(normalizeRequestBody) is now mounted immediately
after express.json() in src/index.ts to restore the Express 4 behaviour. It fills in only a
missing body, so a parsed array or explicit null is untouched.
Operator runbook: backfill:standing (Sprint 126 / ADR-096)
Projects stored completed-match history through the same production path new activity uses. It invents nothing — no matches, no feedback, no score tuning.
# From a SOURCE CHECKOUT (has src/ and ts-node) — e.g. ~/karmyq on the demo host:
npm --workspace karmyq-reputation-service run backfill:standing # dry run (DEFAULT)
npm --workspace karmyq-reputation-service run backfill:standing -- --apply --batch-size 100
# Inside the DEPLOYED CONTAINER, which has only dist/ and installs --omit=dev (no ts-node, no src/):
npm run backfill:standing:dist
npm run backfill:standing:dist -- --apply --batch-size 100
⚠️ Two invocation paths, and the ts-node one does not work in the container. The image copies
dist only and installs without dev dependencies, so backfill:standing (ts-node) is a
source-checkout command. Use backfill:standing:dist in the container. The CLI prints both.
--batch-size N and --batch-size=N are both accepted; positive integer, default 100. Unknown,
missing, non-integer, zero, and negative arguments exit 2 before any database call.
The apply enforces its own verification. After writing, it re-derives every projected row in TypeScript and compares it against what SQL actually wrote; any mismatch throws and the CLI exits non-zero. Rows already committed are not rolled back — the message says so — but you will never be told a data operation succeeded when it did not.
Authority boundary. --apply against demo is a separately authorized data operation, after
deployment and a fresh backup. Deployment approval is not data-operation approval.
Order of operations against a live database:
-
Before deploying, count duplicate projection identities.
CREATE UNIQUE INDEXdoes not tolerate duplicate data —IF NOT EXISTSguards against the index existing, not against duplicate rows — and a conflict aborts the migration and rolls the deploy back:SELECT COUNT(*) FROM ( SELECT 1 FROM reputation.karma_records WHERE related_entity_id IS NOT NULL GROUP BY user_id, community_id, reason, related_entity_id HAVING COUNT(*) > 1) d; -
Deploy schema and code. Prefer a quiet window: migrations apply before images are rebuilt, so the new indexes run against old containers for a few minutes.
-
Take a fresh backup.
-
Dry-run and read the report. Re-measure rather than trusting an earlier audit — the simulator keeps completing matches (7,817 on 2026-08-19 → 7,860 on 2026-08-20).
-
Obtain explicit authorization, then apply in bounded batches.
-
Dry-run again, then apply again. Both must report zero new projection writes. That is the idempotency proof, and it is the point of the whole design.
Resume safety. There is no checkpoint file — the database projection identities are the checkpoint. An interrupted run is resumed by re-running the same command; already-projected matches write nothing.
A zero is a result, not a gap. Every active membership is evaluated, including pairs with no history in that community.
But "no local history" does NOT mean "scores 0" (corrected Sprint 128 — the report used to say
it did, and used to print it). Trust breadth is global: getTrustMetrics' community count
(src/database/trustMetricsDb.ts:26-32) filters on user_id alone, with no community predicate.
So a member who is active in one community and merely joined a second carries that breadth into the
second one:
| Membership | recent | people | communities | Score |
|---|---|---|---|---|
| Active in this community | 1 | 1 | 1 | 10 + (2+3)×0.4 + 5 = 17 |
| No local history, active elsewhere | 0 | 0 | 1 | (0 + 3)×0.4 = 1.2 → 1 |
| No history anywhere | 0 | 0 | 0 | 0 |
1 is not a floor — it is what the default 0.4 breadth weight produces from one community of canonical activity. A member with no history anywhere still scores 0.
⚠️ breadth_weight = 0 does NOT remove all cross-community standing — it removes only the
breadth term. Feedback is the second, independent cross-community channel:
calculateWeightedAvgFeedback blends 70% local / 30% global but returns local ?? global
(database/feedbackDb.ts:39-45), so a member with no LOCAL rating is scored on their global average
whole. Verified against computeTrustScore:
| breadth_weight | prior feedback | Score |
|---|---|---|
| 0 | 5★ elsewhere | 25 |
| 0 | none | 0 |
| 0.4 | 5★ elsewhere | 26 |
| 0.4 | none | 1 |
So only a member with no history and no feedback anywhere scores 0. Setting breadth to zero does not make a community's scoring purely local, and nothing in the current configuration surface does.
zeroHistoryPairs counts pairs with no local history, so it does not equal the '0' score
bucket. Reading it as "how many will score 0" is the mistake the old wording invited.
Its definition was also corrected in Sprint 128: "sourced" now means the membership has any local canonical history, where it previously meant recent interactions or counterparties. That older test silently reported two real cases as zero-history — history older than the 365-day window, and rows with a NULL match id, which is exactly what legacy normalization leaves behind. Ordinary data is unaffected; the counts move only for those two shapes.
Report timing and configuration assumptions. The preview equals the writer at the same dataset,
the same instant and the same configuration. Three things legitimately move the writer's answer
afterwards: the 365-day recency window slides, the simulator keeps completing matches, and
updateTrustScore reads depth/breadth weights through a 4-hour Redis cache
(effectiveParamsCache.ts:10) while the preview reads user_trust_configs directly — so a weight
changed within the last 4 hours can leave the writer using the older value. Rerun the dry run
immediately before applying rather than trusting an earlier report.
Provider counting unit. providerEligibility counts provider-profile/community pairs, keyed
provider_id|community_id — not people. requests.provider_profiles has no community_id and is
unique on (user_id, service_type), so one user offering two service types in one community is two
eligible pairs. Its floors 1/20/40/60 are fixed what-if scenarios; live reach instead uses each
community's configured provider_min_personal_trust_score, so these counts answer "how many pairs
would clear this floor", not "how many are reachable today".
Sprint 131 PR B2 — declared imports (2026-09-17)
Now declares bull, cors, dotenv, express, ioredis, pg in dependencies, and jsonwebtoken in devDependencies at root's exact
ranges (BUG-046). They were imported but undeclared, resolving only through root hoisting. Resolved versions are
unchanged. tests/regression/sprint-131-workspace-declarations.test.ts fails on any undeclared import, and fails
when root's hoisted version stops satisfying a range declared here — so a root-only major bump (e.g. D1 dotenv 17)
must bump this manifest in the same PR. Range satisfaction alone could not enforce that: npm answers a stranded
range by nesting a satisfying older copy under the workspace, which keeps a plain satisfaction check green.
No endpoint, payload, event or schema change.
Sprint 131 D1 — dotenv 17 (2026-09-19)
dotenv 16.6.1 → 17.4.2 (root plus all 9 npm workspaces that declare it; the ranges moved together in
one PR, which the declarations gate requires. The two non-workspace test manifests, tests/e2e/package.json and
tests/load/package.json, deliberately stay on ^16.3.1 with their own nested installs — dotenv 16 accepts
quiet too, so their call sites are fixed either way). src/index.ts now calls dotenv.config({ quiet: true }).
quiet is not cosmetic here. dotenv 16 defaulted it to true; 17 defaults it to falsy, so a bare
config() prints ◇ injected env (N) from .env // tip: … on every call — and the tip is drawn at random
from an 8-entry list that includes third-party promo URLs. Without the flag this service would log a
non-deterministic marketing line on every container start.
tests/regression/sprint-131-dotenv-quiet.test.ts discovers the call sites from tracked source and fails
on any config() that omits quiet.
No endpoint, payload, event or schema change. parse() output is byte-identical between 16 and 17, and
nothing here reads config()’s return value.
Sprint 131 D2 — node-cron 4 (2026-09-21)
node-cron 3.0.3 → 4.6.0 (#228; the two declarers, cleanup-service and reputation-service, moved together).
@types/node-cron is removed from devDependencies: v4 ships its own typings, and tsc --traceResolution
resolves node-cron to the bundled dist/node-cron.d.ts@4.6.0, so the old @types copy described an API that no
longer exists. v4 also drops node-cron's uuid dependency.
Behavior was checked against the installed dist/ source and by running v4, not from the changelog.
No call site passes options, and every v3 default this code depends on is unchanged:
- The default import still works; the CJS build sets
__esModuleandexports.default. schedule()still auto-starts.- The timezone is still process-local.
- Overlapping runs are still allowed.
- A slot missed because the event loop was blocked is still skipped, not replayed. v3's
recoverMissedExecutionsdefaulted off.
One operator-visible change: v4 no longer drops a missed slot silently. It logs
[NODE-CRON] [WARN] missed execution at <date>! … through node-cron's default console logger, which means a
job at that time did not run. It is left on console here: this service's cron jobs log through console
themselves, and the shared Logger.error(message: string, …) cannot take the Error objects that node-cron passes.
(cleanup-service routes it through winston with cron.setLogger.)
Real-scheduler coverage (the sprint-126 test mocks node-cron): tests/regression/sprint-131-node-cron-v4.test.ts.
No endpoint, payload, event or schema change.
Sprint 131 D9 — ioredis 6 (2026-09-29)
ioredis 5.11.1 → 6.0.0, for this service's effective-params cache (src/services/effectiveParamsCache.ts,
the only importer). This supersedes #245, which also moved the root declaration. Here only this workspace moves:
ioredis 6 is nested in services/reputation-service/node_modules/, with its own @ioredis/commands 2.0.0,
debug 4.4.3 and ms 2.1.3. The root declaration stays ^5.11.1, and bull@4 (which pins ioredis ^5.3.2)
keeps resolving the hoisted 5.11.1, so the event queue is untouched. The production image's
npm install --omit=dev was simulated with the Dockerfile's exact COPY set: reputation resolves 6.0.0 and bull 5.11.1.
What changed in v6, checked against the built source of both versions rather than the changelog:
- RESP3 by default (accepted). The client opens with
HELLO 3, thenCLIENTmetadata, then theINFOreadiness check. It falls back to RESP2 onNOPROTOor an unknownHELLO.replyMapping: "legacy"keeps GET/SETEX/DEL reply shapes unchanged. Demo and CI runredis:7-alpine. keepAlive0 → 30000 (accepted). A TCP keepalive only detects a dead socket sooner.- Default retry backoff: PINNED to v5. v6 moved to
min(50·2^(n-1), 5000) + 0–199 ms. ioredis flushes queued commands only every 21st retry (maxRetriesPerRequest20), and the attempt count keeps growing through an outage. So a cache command waits up to one 21-retry window before the DB fallback runs: on v5, about 10.5 s at outage start and up to about 42 s once the delay is capped at 2 s; on v6's default, about 73 s and about 107 s.createCacheClient()passesV5_RETRY_STRATEGY(min(n·50, 2000)), so outage cost is unchanged.
createCacheClient() is new and exported: getRedis() builds its singleton through it, and the gate uses it to
observe the real client.
Known issue (pre-existing, now covered): every Redis call here sits in a silent catch that falls back to the
DB, so an API 200 cannot tell a working client from a broken one. That is also why every suite that mocks this
module proves nothing about the client. Real-client coverage:
tests/regression/sprint-131-ioredis-6.test.ts drives the real ioredis against a loopback RESP server:
- A: resolution split (6 here, 5 for bull).
- B: the exact wire sequence for miss, hit, invalidate and quit, including the 14400 s
SETEX. - C: exact v5 retry delays against a refused port, plus the 2000 ms cap.
- D: RESP2 fallback.
Injections that turn it red: dropping the retry pin → C; protocol: 2 → B and D; hiding the nested ioredis → A.
No endpoint, payload, event, schema, key or TTL change.