Cleanup Service
1
API Endpoints
0
Service Deps
2
Infrastructure
1
DB Schemas
Infrastructure
Publishes Events
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:
- Ephemeral Data (TTL) - Automatic expiration for requests, offers, messages, notifications
- Data Cleanup - Hard deletion of expired data after grace period
- Activity Log Cleanup - Remove transient
reputation.activity_logrows older than 90 days. Rows with arelated_entity_idare 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 - 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 deadrecordActivity()twin was deleted fromsrc/jobs/reputationDecayJob.ts.It used to overwrite
reputation.trust_scores.scorewithmin(100, floor(decayed_karma / 10))— a pre-ADR-037 formula. ADR-039 Phase 2 moved decay INTO the canonical calculator:reputation-service'supdateTrustScore()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 withkarma/10every night.This was invisible because
reputation.trust_scoresheld zero rows platform-wide (BUG-037 — the live karma writer had been raising42703on 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-reviewgate.⚠️ The refresh cadence MOVED, it did not disappear.
computeTrustScorereads 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.tsnow sweeps every active membership daily at 03:30 through the canonicalupdateTrustScore, owned by the service that owns the data. Karma decay itself (ADR-011) is unaffected — it is computed at read time fromkarma_recordstimestamps, never stored here.POST /jobs/update-decaystill exists and still requires admin auth; it now triggers the no-op. Pinned bytests/unit/reputationDecayJob.test.ts.recordActivity()here was a near-verbatim copy of reputation-service's, with zero callers repo-wide. Activity is written byreputation-service/src/utils/activityTracker.ts.
Scheduled Jobs
| Job | Schedule | Description |
|---|---|---|
| Mark Expired | Every 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 Delete | Daily 2:00 AM | Permanently delete data expired >7 days |
| Reputation Decay | Daily 3:00 AM | NO-OP since Sprint 126 — logs and returns; writes nothing |
| Activity Log Cleanup | Weekly Sun 4:00 AM | Remove old activity logs (>90 days) where related_entity_id IS NULL — attributable rows are projection state |
| Decay Report | Weekly Mon 9:00 AM | Generate community decay statistics |
| Expire Dibs | Every 5 minutes | Find pending requests.dibs records past expires_at, set status=expired, reset help_requests.status to open, publish dibs_expired event |
| Trust Edge Sweep | Daily 4:30 AM | Delete social_graph.trust_edges where current_weight < disappearance_threshold (via trust_edges_live view) |
| RETIRED (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 AM | memoryRetentionJob.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:
- Exchange Unit cascade — one data-modifying CTE:
UPDATE requests.help_requests(completed, aged pastcompleted_request_window_days,content_forgotten_at IS NULL) → sentinel + stampcontent_forgotten_at; the second CTE forgets everymessaging.messages.contentwhose 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. - 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 fromupdated_at(the expiration job stamps it when it flips the flag), nevercreated_at. - Message backstop — anonymize any
messages.contentolder thanmessage_window_daysthe 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 (andactivity_types, read by reputation-service)reputation.activity_log- User activity. Written by reputation-service, not here; this service only expires transient rowsreputation.trust_scores- Trust scores withlast_activity_atrequests.help_requests-expires_at,expiredcolumns;statusreset toopenon dibs expiry; Sprint 90:content_forgotten_atmarker (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_atcolumns (Sprint 42)requests.help_offers-expires_at,expiredcolumnsmessaging.messages-expires_at,expiredcolumns; Sprint 90:contentanonymized +forgotten_atmarker on cascadenotifications.notifications-expires_at,expiredcolumns
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:
-
Batch Delete: Already implemented in
batchHardDelete()await batchHardDelete('requests.help_requests', 1000); -
Indexes: Created on
expires_atandexpiredcolumnsCREATE INDEX idx_help_requests_expires_at ON requests.help_requests(expires_at) WHERE expired = FALSE; -
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+requestLoggingMiddlewarefrom@karmyq/shared/utils/loggertosrc/index.ts— request-scoped logging now active - FIXED: Replaced
anytypes in Express middleware helpers with typedExtendedRequest,Response,NextFunctioninterfaces - FIXED: Catch variable
error: anyreplaced withunknown+instanceof Errornarrowing - FIXED:
db.tsquery helper params typed asunknown[]instead ofany[] - 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
__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 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. maxstill maps tolimit.standardHeaders: truestill selectsdraft-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/jsmajors match; - (B)
eslint srcexits 0 and actually lintssrc; - (C) the three new rules fire on a
.tsprobe, so a config that stops applyingjs.configs.recommendedcannot pass B by checking almost nothing.
No endpoint, event, schema or runtime change. The production image installs with --omit=dev and contains
no eslint.