Docs·8777c5dd·Updated Aug 7, 2026·95 ADRs
All Services

Cleanup Service

Port 3008productionimportant

1

API Endpoints

0

Service Deps

2

Infrastructure

1

DB Schemas

Infrastructure

postgresredis

Publishes Events

dibs_expired

Full Documentation

Cleanup Service Context

Port: 3008 Purpose: Automated data expiration and retention management Tech: Node.js, TypeScript, PostgreSQL, node-cron

What This Service Does

The Cleanup Service handles:

  1. Ephemeral Data (TTL) - Automatic expiration for requests, offers, messages, notifications
  2. Data Cleanup - Hard deletion of expired data after grace period
  3. Activity Log Cleanup - Remove transient reputation.activity_log rows older than 90 days. Rows with a related_entity_id are projection state (ADR-096) and are deliberately spared: deleting them would make the standing backfill re-predict and rewrite them forever, so it could never report convergence
  4. Decay Reporting - Read-only community decay statistics

⚠️ This service does NOT compute or write trust scores (Sprint 126 / ADR-096). See Recent Changes below.

Recent Changes

  • 2026-08-21 (Sprint 126 / ADR-096 — the decay job no longer writes trust scores): updateDecayedTrustScores() is now a logging no-op, and the dead recordActivity() twin was deleted from src/jobs/reputationDecayJob.ts.

    It used to overwrite reputation.trust_scores.score with min(100, floor(decayed_karma / 10)) — a pre-ADR-037 formula. ADR-039 Phase 2 moved decay INTO the canonical calculator: reputation-service's updateTrustScore() already weights interactions by a 12-month recency window and blends recency-weighted feedback. So this second, cruder formula did not add decay — it replaced the real multi-signal score with karma/10 every night.

    This was invisible because reputation.trust_scores held zero rows platform-wide (BUG-037 — the live karma writer had been raising 42703 on every completed match since Sprint 62), so the job selected nothing and logged "No trust scores to update". The moment Sprint 126's backfill populates that table, the old behaviour would have wiped every projected score within 24 hours and re-emptied ADR-095's provider reach gate. Found by the Sprint 126 /code-review gate.

    ⚠️ The refresh cadence MOVED, it did not disappear. computeTrustScore reads a moving 12-month window, so a stored score only decays if something recomputes it — and the ADR-095 provider reach gate reads the CACHED value. Simply deleting this job would have frozen dormant providers as permanently eligible. reputation-service/src/cron/trustScoreRefresh.ts now sweeps every active membership daily at 03:30 through the canonical updateTrustScore, owned by the service that owns the data. Karma decay itself (ADR-011) is unaffected — it is computed at read time from karma_records timestamps, never stored here. POST /jobs/update-decay still exists and still requires admin auth; it now triggers the no-op. Pinned by tests/unit/reputationDecayJob.test.ts.

    recordActivity() here was a near-verbatim copy of reputation-service's, with zero callers repo-wide. Activity is written by reputation-service/src/utils/activityTracker.ts.

Scheduled Jobs

JobScheduleDescription
Mark ExpiredEvery hour (:00)Soft delete data past expires_at (help_requests filtered to status = 'open' — Sprint 85 dropped the phantom 'pending' token, never a real help_requests status)
Hard DeleteDaily 2:00 AMPermanently delete data expired >7 days
Reputation DecayDaily 3:00 AMNO-OP since Sprint 126 — logs and returns; writes nothing
Activity Log CleanupWeekly Sun 4:00 AMRemove old activity logs (>90 days) where related_entity_id IS NULL — attributable rows are projection state
Decay ReportWeekly Mon 9:00 AMGenerate community decay statistics
Expire DibsEvery 5 minutesFind pending requests.dibs records past expires_at, set status=expired, reset help_requests.status to open, publish dibs_expired event
Trust Edge SweepDaily 4:30 AMDelete social_graph.trust_edges where current_weight < disappearance_threshold (via trust_edges_live view)
Request TTL SweepRETIRED (Sprint 90 / ADR-069)requestTtlSweepJob (sweepExpiredRequests) hard-deleted completed+rated requests + their matches (cascade-deleting conversations/messages) at 30 days — destroyed the aggregate ADR-069 keeps, before the 180-day anonymize window. Superseded by Memory Retention (anonymize, keep aggregates). Job file, cron, and /jobs/sweep-request-ttl endpoint removed.
Memory Retention (Sprint 90 / ADR-069)Daily 3:30 AMmemoryRetentionJob.forgetExchangeContent() — anonymize aged completed-request free-text (title/description/payload/requirements → '[forgotten]'/'{}') and cascade-forget its conversation's messages.content in one atomic CTE (the Exchange Unit); hard-delete expired = TRUE + unmatched requests aged from updated_at; backstop old messages. reputation.karma_records is never touched (no PII; reason is a load-bearing enum). Windows resolve per-request: per-community override → global → fallback (MAX over the request's communities via request_communities → retention_config). Manual trigger: POST /jobs/forget-content.

Memory Retention Job (Sprint 90 — ADR-069)

src/jobs/memoryRetentionJob.ts. Three statements per run, each idempotent via partial-index predicates:

  1. Exchange Unit cascade — one data-modifying CTE: UPDATE requests.help_requests (completed, aged past completed_request_window_days, content_forgotten_at IS NULL) → sentinel + stamp content_forgotten_at; the second CTE forgets every messaging.messages.content whose conversation links (request → match → conversation, conversations.request_match_id) to a just-forgotten request. Atomic — request text and its messages forget together or not at all.
  2. Expired hard-delete — DELETE FROM requests.help_requests WHERE expired = TRUE AND NOT EXISTS (a match) AND updated_at < now() - expired_request_window_days. Age from updated_at (the expiration job stamps it when it flips the flag), never created_at.
  3. Message backstop — anonymize any messages.content older than message_window_days the cascade missed (forgotten_at IS NULL).

Per-community windows: the completed-anonymize and expired-delete branches resolve each request's effective window in SQL as MAX(COALESCE(community_override, global)) over the request's communities (request_communities → retention_config) — the conservative choice, so a shared request is never forgotten earlier than ANY owning community wants; a request with no community rows uses the global default. The standalone message backstop uses the global window (a loose message isn't reliably attributable to one community; per-community message retention is honored via the cascade).

resolveRetentionWindows(rows, communityId?) is a pure exported helper (community → global → hardcoded fallback {180, 30, 180}) used to resolve the global default passed to the SQL. Config table: requests.retention_config (partial unique index on the NULL global row + WHERE NOT EXISTS guarded seed). Marker columns: help_requests.content_forgotten_at, messages.forgotten_at, each with a partial index WHERE ... IS NULL.

Sprint 90 retired requestTtlSweepJob (sweepExpiredRequests): it hard-deleted completed+rated requests and their matches (cascade-deleting conversations + messages) at 30 days, which both destroyed the aggregate ADR-069 keeps and fired before the 180-day anonymize window. The memory retention job now owns the completed-request lifecycle. The job file, its cron, and /jobs/sweep-request-ttl were removed.

Database Schema

Tables Used

  • communities.settings - Per-community TTL configuration (and activity_types, read by reputation-service)
  • reputation.activity_log - User activity. Written by reputation-service, not here; this service only expires transient rows
  • reputation.trust_scores - Trust scores with last_activity_at
  • requests.help_requests - expires_at, expired columns; status reset to open on dibs expiry; Sprint 90: content_forgotten_at marker (anonymization stamp)
  • requests.retention_config - Sprint 90 (ADR-069): per-community + global retention windows (completed_request_window_days/expired_request_window_days/message_window_days)
  • requests.dibs - status, expires_at columns (Sprint 42)
  • requests.help_offers - expires_at, expired columns
  • messaging.messages - expires_at, expired columns; Sprint 90: content anonymized + forgotten_at marker on cascade
  • notifications.notifications - expires_at, expired columns

Functions Used

communities.calculate_expires_at(community_id, entity_type, created_at)
  → Returns expiration timestamp based on community TTL settings

reputation.calculate_decayed_karma(user_id, community_id)
  → Returns karma with exponential time decay applied (ADR-011).
  ⚠️ NOT used by this service since Sprint 126 — it fed the removed trust-score formula.
  Karma decay is applied at READ time by reputation-service; nothing here derives a score.
  → Formula: karma * 0.5^(months_ago / half_life_months)

Security (Sprint 54 — ADR-052)

SQL Injection Protection — batchHardDelete()

batchHardDelete() in src/jobs/expirationJob.ts interpolates a table name directly into a SQL query. Sprint 54 added ALLOWED_CLEANUP_TABLES (exported constant) that gates all calls:

export const ALLOWED_CLEANUP_TABLES = new Set([
  'requests.help_requests', 'requests.help_offers',
  'messaging.messages', 'notifications.notifications',
]);

Any table name not in this set throws immediately before any DB call. This prevents SQL injection via the table parameter.

Schema Typo Fix

src/index.ts admin auth check queried community.members (wrong schema). Fixed to communities.members. The bug caused all admin-authenticated endpoints to always return 403.

API Endpoints (Manual Triggers)

All endpoints are for testing/admin purposes. Normal operation uses cron.

POST /jobs/mark-expired
  // Manually run expiration job

POST /jobs/hard-delete
  // Manually run hard delete job

POST /jobs/update-decay
  // NO-OP since Sprint 126 (ADR-096). Returns success with noop: true and writes nothing.
  // Trust scores are recalculated by reputation-service's own 03:30 canonical sweep.

POST /jobs/cleanup-activity-logs
  // Expire transient activity rows older than 90 days.
  // Rows with a related_entity_id are projection state and are NOT deleted.

GET /jobs/decay-report
  // Generate decay report (check logs)

POST /jobs/sweep-trust-edges
  // Manually trigger trust edge sweep (delete decayed edges below threshold)

POST /jobs/forget-content
  // Manually trigger memory retention (Sprint 90 / ADR-069): anonymize aged completed-request
  // free-text + cascade-forget messages, hard-delete expired/unmatched requests. Karma untouched.

GET /health
  // Service health check

Configuration

Environment variables:

PORT=3008
DATABASE_URL=postgresql://user:pass@host:port/db
LOG_LEVEL=info  # debug, info, warn, error

How It Works

1. Expiration Flow

Hourly Job
├─ Query items where expires_at <= NOW() AND expired = FALSE
├─ Set expired = TRUE (soft delete)
└─ Log count of expired items

Daily Job (2 AM)
├─ Query items where expired = TRUE AND updated_at <= (NOW() - 7 days)
├─ DELETE permanently (hard delete)
└─ Log count of deleted items

2. Reputation Decay Flow — this service no longer participates

Daily Job (3 AM)  ← retained so the schedule stays visible; writes NOTHING
└─ Log that trust scores are owned by reputation-service

Daily Job (3:30 AM, reputation-service/src/cron/trustScoreRefresh.ts)
├─ For each ACTIVE membership in communities.members:
│   └─ updateTrustScore(user_id, community_id)   ← the canonical ADR-037 calculator
└─ Log evaluated / failed counts

Until Sprint 126 the 3 AM job here overwrote reputation.trust_scores.score with min(100, floor(decayed_karma / 10)) — a pre-ADR-037 formula. ADR-039 Phase 2 had already moved decay INTO the canonical calculator (a 12-month moving interaction window plus recency-weighted feedback), so this second formula did not add decay, it replaced the real multi-signal score with a cruder one every night.

That was invisible only because reputation.trust_scores held zero rows platform-wide (BUG-037 — the live karma writer had been throwing 42703 since Sprint 62), so the job selected nothing. The moment Sprint 126's backfill populated the table it would have wiped every projected score within 24 hours and re-emptied ADR-095's provider reach gate. updateDecayedTrustScores() is now a logging no-op, pinned by four tests in tests/unit/reputationDecayJob.test.ts, and the refresh cadence moved to reputation-service, which owns both the table and the calculator (ADR-096).

Karma decay itself (ADR-011) is unaffected — it is computed at READ time from karma_records timestamps and was never stored here.

3. Decay Formula (ADR-011 — applied at READ time by reputation-service)

This is the platform's karma-decay formula and it is still live. It is documented here because reputation.calculate_decayed_karma sits in a schema this service reads, not because this service applies it — since Sprint 126 nothing here calls it.

decayed_karma = Σ (karma_points * 0.5^(months_ago / half_life_months))

Example (6-month half-life):
- Karma earned today: 100 * 1.0 = 100 points
- Karma from 6 months ago: 100 * 0.5 = 50 points
- Karma from 12 months ago: 100 * 0.25 = 25 points
- Karma from 18 months ago: 100 * 0.125 = 12.5 points

4. Activity Tracking

When users complete exchanges:

  • complete_request - Helped someone (responder)
  • complete_offer - Received help (requester)

These rows are written by reputation-service (utils/activityTracker.ts), which upserts reputation.trust_scores.last_activity_at to GREATEST(existing, new) so a historical replay can never move a user's last activity backwards. Cleanup-service only ever reads this column (for the decay report) and prunes unattributable activity_log rows.

Common Tasks

Manually Run a Job

Every /jobs/* endpoint requires a Bearer token whose userId is an active admin of at least one community (adminAuthMiddleware, src/index.ts:97); without one they return 401/403.

TOKEN=... # JWT for a user with role='admin' in communities.members

# Mark expired data
curl -X POST http://localhost:3008/jobs/mark-expired -H "Authorization: Bearer $TOKEN"

# Reputation decay — succeeds but does nothing (Sprint 126 / ADR-096); trust scores are
# recalculated by reputation-service's 03:30 sweep, not here.
curl -X POST http://localhost:3008/jobs/update-decay -H "Authorization: Bearer $TOKEN"

# Generate decay report (read-only; logs the report)
curl http://localhost:3008/jobs/decay-report -H "Authorization: Bearer $TOKEN"
# Then check logs: docker logs karmyq-cleanup-service

Check Job Logs

# Real-time logs
docker logs -f karmyq-cleanup-service

# Last 100 lines
docker logs --tail 100 karmyq-cleanup-service

# Specific job
docker logs karmyq-cleanup-service | grep "Reputation decay"

Query Activity Data

-- Recent activities
SELECT * FROM reputation.activity_log
ORDER BY created_at DESC
LIMIT 20;

-- User's last activity
SELECT user_id, community_id, last_activity_at
FROM reputation.trust_scores
WHERE user_id = 'user-uuid'
ORDER BY last_activity_at DESC;

-- Community decay stats
SELECT
  c.name,
  AVG(EXTRACT(EPOCH FROM (NOW() - ts.last_activity_at)) / (30.44 * 24 * 60 * 60)) as avg_months_inactive
FROM communities.communities c
JOIN reputation.trust_scores ts ON c.id = ts.community_id
GROUP BY c.name
ORDER BY avg_months_inactive DESC;

Monitoring

Key Metrics

Watch logs for:

  • Items expired per hour
  • Items deleted per day
  • Activity-log rows deleted per week (unattributable rows only — see below)
  • Errors/failures

Trust-score updates are NOT a metric of this service; watch reputation-service's 03:30 refresh (evaluated / failed counts) instead.

Health Checks

# Service health
curl http://localhost:3008/health

# Response
{
  "status": "healthy",
  "service": "cleanup-service",
  "uptime": 3600,
  "timestamp": "2025-01-15T12:00:00Z"
}

Troubleshooting

Jobs Not Running

Check cron patterns:

// In src/index.ts
cron.schedule('0 * * * *', ...) // Every hour
cron.schedule('0 2 * * *', ...) // Daily 2 AM

Verify timezone: Cron uses server timezone. Check:

docker exec karmyq-cleanup-service date

Too Much Data Deleted

Check TTL settings:

SELECT community_id, request_ttl_days, offer_ttl_days, message_ttl_days
FROM communities.settings
WHERE request_ttl_days < 30; -- Find aggressive settings

Adjust grace period: Edit expirationJob.ts:

const deleteThreshold = new Date();
deleteThreshold.setDate(deleteThreshold.getDate() - 7); // Change this

Reputation Decay Too Aggressive

Check half-life settings:

SELECT community_id, reputation_half_life_months
FROM communities.settings
WHERE reputation_half_life_months < 6;

Test decay calculation:

SELECT reputation.calculate_decayed_karma('user-uuid', 'community-uuid');

Performance Considerations

Large Datasets

For communities with millions of records:

  1. Batch Delete: Already implemented in batchHardDelete()

    await batchHardDelete('requests.help_requests', 1000);
    
  2. Indexes: Created on expires_at and expired columns

    CREATE INDEX idx_help_requests_expires_at
    ON requests.help_requests(expires_at)
    WHERE expired = FALSE;
    
  3. Off-Peak: Jobs run at 2-4 AM (low traffic)

Database Load

  • Mark Expired (hourly): Low load, updates only
  • Hard Delete (daily): Moderate load, batch deletes
  • Decay Update (daily): None — the job is a no-op and issues no query at all

For very large platforms, consider:

  • Partition activity_log by created_at
  • Cache community settings

The trust-score sweep that used to dominate this service's load now runs in reputation-service (03:30, one updateTrustScore call per active membership); batching belongs there, not here.

Security

Soft Delete First

  • 7-day grace period allows recovery
  • Admins can restore expired data before hard delete
  • Audit trail in activity_log

Access Control

Every /jobs/* endpoint is already gated — adminRateLimiter then adminAuthMiddleware (src/index.ts:173-267); only /health is open. An earlier version of this section said the endpoints were "currently open for testing" and suggested adding authentication. That was stale and wrong in the more dangerous direction, describing an authenticated admin surface as unprotected.

Integration

Event Flow

Match Completed
  ↓
Reputation Service (ONE transaction — ADR-096)
  ├─ Award Karma          (reputation.karma_records, keyed by related_entity_id = match id)
  ├─ Update Trust Score   (reputation.trust_scores, canonical ADR-037 calculator)
  └─ Record Activity      (reputation.activity_log, same projection identity)
       ↓
Cleanup Service (Weekly Sun 4 AM)
  └─ Delete activity_log rows older than 90 days WHERE related_entity_id IS NULL

⚠️ The activity log is reputation-service's table, not a cleanup-service one, and rows carrying a related_entity_id are projection state rather than disposable log noise: they hold a projection identity (uq_activity_match_projection) and the standing backfill compares stored rows against replay to decide it has converged. Deleting them would make retention and projection permanently incompatible — the backfill writes rows stamped with the match's real completion time, usually far older than 90 days, this sweep would remove them, the next dry run would predict them again, and the run could never report convergence. Unattributable rows (logins and similar, related_entity_id IS NULL) still expire on the unchanged 90-day window; they carry no free text, so ADR-069 memory-retention concerns do not apply.

Cleanup Service no longer recalculates decay — see Reputation Decay Flow above.

Dependencies

  • PostgreSQL: Required (all jobs query DB)
  • Redis: Not used (could add for job locks in multi-instance setup)
  • Other Services: Independent (can run standalone)

Recent Changes

Sprint 44: Structured Logging + Type Safety (2026-04-04)

  • NEW: Added createLogger + requestLoggingMiddleware from @karmyq/shared/utils/logger to src/index.ts — request-scoped logging now active
  • FIXED: Replaced any types in Express middleware helpers with typed ExtendedRequest, Response, NextFunction interfaces
  • FIXED: Catch variable error: any replaced with unknown + instanceof Error narrowing
  • FIXED: db.ts query helper params typed as unknown[] instead of any[]
  • Route handler cron and admin endpoint errors now emit structured { service, step/endpoint, error.message } via (req as any).logger?.error()

Future Enhancements

  • Redis-based job locks for multi-instance deployment
  • Webhook notifications for decay events
  • Admin UI for job management
  • Configurable job schedules per community
  • Data export before hard delete
  • Metrics export (Prometheus)

Last Updated: 2025-01-15 Version: 5.1.0 Related: See docs/PHASE3_EPHEMERAL_DATA_DECAY.md for full documentation


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.

No route file changed in this service — only the @types/express range; it type-checks clean at 0 errors under Express 5. Every async handler here try/catches internally and answers via the inline sendInternalError, so no rejection escapes to Express 5's new auto-forwarding — which is why this service needs no express error middleware.

Also fixed here: @karmyq/shared was imported but never declared. src/index.ts imports createLogger and requestLoggingMiddleware from it, while an inline comment claimed the service "doesn't use shared package" — stale and wrong. Now declared as "*", and the comment corrected to say what is actually true: the service consumes the shared logger but deliberately keeps its own response helpers.

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.

Sprint 131 PR B2 — declared imports (2026-09-17)

Now declares cors, dotenv, express, express-rate-limit, jsonwebtoken, pg, winston in dependencies 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 __esModule and exports.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 recoverMissedExecutions defaulted off.

One operator-visible change: v4 no longer drops a missed slot silently. It warns missed execution at <date>! …, and a missed execution warning means a job at that time did not run. src/index.ts calls cron.setLogger(logger), so the warning and node-cron's task errors go through this service's winston logger rather than node-cron's default console logger. All nine cron.schedule expressions in src/index.ts are valid under v4, and each arms at its intended local time.

No endpoint, payload, event or schema change.

Sprint 131 D3 — express-rate-limit 8 (2026-09-21)

express-rate-limit → 8.7.0 (#224). One PR moves root, packages/shared, cleanup-service and geocoding-service. shared and geocoding were deliberately held back on 7.5.1 and are now on 8. Their two DIVERGENCE_ALLOWLIST entries in tests/regression/sprint-131-workspace-declarations.test.ts are removed: the gate's stale-entry test went red on #224 as soon as they stopped being divergences.

I checked behavior against the installed dist/index.cjs of both versions and with a real-Express probe:

  • CommonJS require() still returns the function.
  • max still maps to limit.
  • standardHeaders: true still selects draft-6.
  • The default key generator is still per-IP. v8 applies a /56 subnet to IPv6.
  • None of v8's new validations fires for any option shape used here, and no test output contains ERR_ERL.

v8.7.0 adds a runtime dependency, debug@^4.4.3, installed under express-rate-limit's own folder because root has debug@2.6.9.

This service was already on 8, so for it this is a minor bump, 8.5.2 → 8.7.0. adminRateLimiter uses the default key generator.

No endpoint, payload or event change.

Sprint 131 D8 — eslint 10 (2026-09-28)

eslint 9.39.5 → 10.11.0 and @eslint/js 9.39.5 → 10.0.1, devDependencies only, moved as a pair. This supersedes #244, which bumped @eslint/js alone and left it on the hoisted eslint 9, outside its eslint ^10.0.0 peer range. Only this workspace is on eslint 10. The root, apps/frontend, apps/landing and apps/mobile stay on 9.39.5, so eslint 10 and its tree are nested under this service's node_modules/. That includes the cacheable/keyv chain behind file-entry-cache@11, with @keyv/bigmap nested under @cacheable/memory so its keyv ^5.6.0 peer resolves. The root declarations gate (tests/regression/sprint-131-workspace-declarations.test.ts) carries a DIVERGENCE_ALLOWLIST entry for this workspace's eslint@^10.11.0. Once root moves to 10, its stale-entry check fails until the entry is removed.

@eslint/js 10's recommended adds three rules: no-unassigned-vars, no-useless-assignment and preserve-caught-error. They raised one finding. batchHardDelete in src/jobs/expirationJob.ts initialized let batchDeleted = 0;, a value the do body always overwrote before the while condition read it. It is now let batchDeleted: number;. TypeScript proves definite assignment through do/while, and the new batchHardDelete unit tests pin the loop's behavior unchanged.

New gate: tests/regression/sprint-131-eslint-10.test.ts. CI's lint step cannot fail (ci.yml and test.yml end it in || echo), so until now nothing stopped a lint break here. The gate runs the eslint binary this workspace resolves, and checks three things:

  • (A) the eslint and @eslint/js majors match;
  • (B) eslint src exits 0 and actually lints src;
  • (C) the three new rules fire on a .ts probe, so a config that stops applying js.configs.recommended cannot pass B by checking almost nothing.

No endpoint, event, schema or runtime change. The production image installs with --omit=dev and contains no eslint.