patchPreferencesRequest forwarded inbound headers (including
content-length from the browser PATCH body) but set a DIFFERENT body
({ preferences }). The Request constructor does NOT recompute
content-length when an explicit body is provided alongside forwarded
headers, so user-service waited for bytes that never arrived and closed
the socket (~2.8s 'other side closed') → uncaught → 500.
Delete content-length and transfer-encoding before constructing the
synthetic request so undici recomputes them from the actual body. Apply
the same defensive deletes in fetchUserProfile.
Extract patchPreferencesRequest into the DB-free
src/services/user-profile.ts module (alongside fetchUserProfile) so the
regression test imports the REAL function and asserts content-length /
transfer-encoding / host / cookie are stripped and the body is the
synthetic { preferences } blob. tsc clean; existing onboarding tests
pass.
PATCH /onboarding read its JSON body via c.req.json() (consuming
c.req.raw), then passed c.req.raw to fetchUserProfile → fetchUserService,
which called req.arrayBuffer() on the already-consumed body:
TypeError: Body is unusable. The global onError caught it as a 500
{"error":"internal"}, so every access-choice save failed. GET
/onboarding worked only because it never reads a body.
fetchUserProfile is a read; it must never replay the inbound method or
body. Extract it (+ userServiceTarget) into a DB-free module that issues
an explicit GET /me with forwarded headers, sidestepping body reuse
entirely and fixing a latent wrong-method bug (PATCH /onboarding would
have issued a PATCH /me write for a read).
Verified against staging logs (TypeError stack at fetchUserService →
fetchUserProfile → PATCH handler). Focused regression test consumes the
body first and asserts GET + no body re-read + profile parse; tsc clean.
Root cause: recordGrowEvent returned the existing row on dedupe hits, and
routeGrowEventToUserActor was called unconditionally — so every duplicate
ingest re-enqueued the same event to the actor queue, saturating it.
Fix at the persistence boundary:
- recordGrowEventWithResult returns {event, inserted} using atomic
onConflictDoNothing().returning() (row present = fresh insert, no row =
dedupe hit). No clock heuristics, no xmax probe.
- Conflict lookup tries dedupeKey first, then id PK fallback, covering both
constraint types.
- shouldRouteGrowEvent(event, inserted) gates routing to only newly-inserted,
user-resolved, pending events. Dedupe hits never re-enqueue.
- recordGrowEvent kept as thin .event wrapper — zero changes to ~10 callers.
Fix the actor run loop (mirrors workflow-run-actor.ts):
- tryStep with catch:['timeout','exhausted'] replaces step+rethrow. Per-event
failure is marked terminal in a separate record-failure step, loop advances.
- isProcessableStatus guard allows pending/processing/processed — only skips
failed/unresolved. This ensures mission reducers (actor-only) still run on
events that got synchronous projections.
- Status guard + claim moved inside tryStep's run for atomic retry unit.
- onError handler added to workflow().
Ingress callers migrated (3 paths):
- redis-consumer recordAndRoute: shouldRouteGrowEvent gate + always mark
processed after projections.
- routes/events.ts ingest: shouldRouteGrowEvent gate + always mark processed.
- v1/events/events-routes.ts /track: inserted flag for dedupe skip.
Tests: 3 new (event-dedupe-routing, user-event-actor-advance,
event-ingress-route-integration with injectable enqueue proving 5x duplicate
ingest enqueues exactly once). All existing touched-scope tests green.
Co-authored-by: independent review (2 rounds)
- Gate both Redis ingestion paths (canonical + legacy) behind separate
default-off flags (GROW_EVENTS_REDIS_ENABLED, LEGACY_SERVICE_REDIS_ENABLED).
The redis module is never imported when both flags are off.
- Sever legacy URL cascade: interview/roleplay/resume/courses Redis URLs
resolve ONLY from their own explicit env vars, never from REDIS_URL or
GROW_EVENTS_REDIS_URL.
- Export resolveRedisConfig() pure resolver for testability.
- Fix onboarding signal ID from 'onboarding.completed_baseline' to
'onboarding.completed' (registry-valid in all v2 formula JSONs).
- Ensure interview/roleplay completed events emit at least a single
completion signal when session_count/scenario_count is absent (default 1).
- Extract computeStreakFromDays() into streak-utils.ts (pure, no DB dep)
with defensive dedup so duplicate days never inflate streaks.
- Add focused tests: redis-gating, signal-registry membership, count
fallback, streak policy (current/longest/duplicate/gap/recovery/7-day),
service-ingest projector behavior.
- typecheck: 80 pre-existing errors unchanged, 0 new.
The social-branding service routes are at root level (/accounts/connect,
/profiles/fetch-by-url, etc.) — NOT under /api/v1/. The proxy was
prepending /api/v1/ to every forwarded path, causing 404 Not Found on
all social POST endpoints including LinkedIn account connect.
The social-branding proxy was a raw passthrough that never called
recordGatewayEvent — so no grow_events rows were created for any social
action (account connect, profile polish, post publish, etc.), breaking
the curator/streak/QScore loop.
Follow the proxyResumeRequest pattern exactly:
- GET/HEAD pass through directly (no event)
- POST/PUT/PATCH/DELETE buffer response, record gateway event, return
- Add socialEventTypeForRest() mapping 10+ social routes to event types
- Add 'social' to canonicalSubjectServiceId mapping
- Event payload is strictly {path, method, status, action} — no raw
cookies, tokens, passwords, or LinkedIn session data
- Extract account_id from path/response for correlation.externalId