Skip to content

feat(kiloclaw): implement impact referrals#2643

Merged
jeanduplessis merged 32 commits intomainfrom
impact-refferals
May 7, 2026
Merged

feat(kiloclaw): implement impact referrals#2643
jeanduplessis merged 32 commits intomainfrom
impact-refferals

Conversation

@jeanduplessis
Copy link
Copy Markdown
Contributor

@jeanduplessis jeanduplessis commented Apr 21, 2026

Summary

Implements the KiloClaw Impact referral program end to end, with Kilo-owned attribution, eligibility, rewards, billing fulfillment, Impact Advocate reporting, and reviewer/support tooling.

  • Adds the KiloClaw referral spec and updates the affiliate/billing specs so referral-priority attribution, free-month fulfillment, Impact reward redemption, GDPR cleanup, and failure behavior are explicit.
  • Adds the referral data model and a regenerated branch-local migration (0117_ambiguous_mad_thinker.sql) for attribution touches, Advocate participants/attempts, referral conversions, reward decisions, rewards/applications, Impact conversion reports, Impact reward redemptions, and deleted-user tombstones.
  • Captures referral and affiliate touches through auth/signup, preserves tracking params across redirects, registers Advocate participants server-side, exposes Verified Access tokens for the referral widget, and mounts Impact identify at the app root.
  • Resolves referral-vs-affiliate priority at first paid KiloClaw conversion time, enforces referee/referrer eligibility and the 12-month referrer cap, records dual-beneficiary outcomes atomically, and fail-closes when reward-bearing referral config is missing.
  • Applies earned free-month rewards to KiloClaw billing across Stripe-funded, hybrid, and pure-credit flows, queues Impact conversion reports, queues Impact Advocate reward redemption after local application, and handles refunds/disputes/fraud with cancellation or support-review states.
  • Adds user-facing referral/reward surfaces, an admin referrals investigation and eligibility override panel, targeted tests, local referral seed scenarios, tunnel/dev docs, and DB bootstrap verification tooling.

Verification

Manual verification previously recorded for this PR:

  • Reviewed behavior against .specs/kiloclaw-referrals.md, .specs/kiloclaw-affiliates.md, and .specs/kiloclaw-billing.md.
  • Reset and reseeded the local dev database with referral happy-path, pending-referrer, cap-boundary, and support-override scenarios.
  • Queried Postgres to confirm referral conversions, decisions, rewards, reward applications, participant rows, and Impact conversion report states matched expected behavior.
  • Exercised a local fake-login referral signup/profile flow, including referral parameter preservation and the unconfigured Impact widget fallback state.
  • Verified the billing side-effects route fail-closes a referral-winning paid conversion when reward-bearing config is incomplete, recording referral_missing_configuration, a failed Impact report, and no granted rewards.
  • Reset the local DB and verified Drizzle bootstrap from an empty schema after the DB reset tooling fix.

Visual Changes

N/A — screenshots were not captured in this body refresh. UI changes are included for the KiloClaw Refer & Earn entry point, billing reward status/summary cards, the logged-in Impact Advocate referral card fallback, and the admin referral investigation panel.

Reviewer Notes

  • The source-of-truth affiliate spec file is now .specs/kiloclaw-affiliates.md; the old .specs/impact-affiliate-tracking.md name no longer exists on this branch.
  • The branch includes one regenerated migration after rebase: packages/db/src/migrations/0117_ambiguous_mad_thinker.sql.
  • Referral reward state is app-owned. Impact Advocate state is used for sharing/reporting/reconciliation only; local eligibility, caps, billing fulfillment, and adverse-payment handling remain authoritative.
  • Conversion processing now decides referral vs affiliate attribution before enqueueing affiliate SALE work, so Stripe, credit-billing, billing-side-effects, cron, and worker paths share one winner.
  • Impact Advocate reward redemption is asynchronous after local reward application. Lookup/redeem failures are retryable and do not block billing settlement or user access.
  • Reward application extends the next unpaid KiloClaw renewal boundary rather than issuing account credits. Already-applied rewards from chargebacks/refunds/fraud move to support review instead of being automatically clawed back.
  • Local dev DB reset behavior now drops all app-owned schemas, not only public, so Drizzle bootstrap state is cleared correctly.
  • Full widget happy-path verification still depends on the test origin being allowlisted in Impact Advocate/SaaSquatch; the local fallback path was verified without that configuration.
Architecture overview
flowchart LR
  subgraph Browser["Browser"]
    User["Referrer / Referee"]
    UTT["Impact UTT + ImpactIdentify"]
    Widget["Verified Access referral card"]
  end

  subgraph Web["apps/web"]
    Token["/api/impact-advocate/token"]
    Auth["Auth, after-sign-in, createOrUpdateUser"]
    Referral["touch capture + participant queueing"]
    Conversion["kiloclaw-referrals conversion/reward engine"]
    RewardApply["local reward application"]
    Cron["/api/cron/dispatch-affiliate-events"]
    Affiliate["affiliate event queue"]
  end

  subgraph Billing["Billing triggers"]
    Stripe["Stripe webhook paths"]
    Credit["Credit enrollment / billing router"]
    Worker["kiloclaw-billing worker"]
    SideEffects["billing-side-effects route"]
  end

  subgraph DB["Postgres"]
    Touches[("kiloclaw_attribution_touches")]
    Participants[("impact_advocate_participants / attempts")]
    Ledger[("referrals / conversions / decisions / rewards / applications")]
    Reports[("impact_conversion_reports")]
    Redemptions[("impact_advocate_reward_redemptions")]
    AffiliateEvents[("user_affiliate_events")]
  end

  subgraph Impact["Impact"]
    Advocate["Impact Advocate Verified Access, participants, rewards"]
    Performance["Impact Performance Conversions API"]
  end

  User --> UTT
  User --> Widget
  Widget --> Token
  Token --> Participants
  Widget --> Advocate

  User --> Auth
  Auth --> Referral
  Referral --> Touches
  Referral --> Participants

  Stripe --> Conversion
  Credit --> Conversion
  Worker --> SideEffects
  SideEffects --> Conversion

  Conversion --> Touches
  Conversion --> Participants
  Conversion --> Ledger
  Conversion --> Reports
  Conversion -->|returns affiliate winner| Affiliate
  Affiliate --> AffiliateEvents
  RewardApply --> Ledger
  RewardApply --> Redemptions

  Cron --> Participants
  Cron --> Reports
  Cron --> Ledger
  Cron --> Redemptions
  Cron --> AffiliateEvents
  Cron --> Advocate
  Cron --> Performance

  Reports -->|dispatched by cron or immediate retry| Performance
  Redemptions -->|queued after local application; dispatched by cron| Advocate
Loading

Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
Comment thread .specs/kiloclaw-referrals.md Outdated
@jeanduplessis jeanduplessis marked this pull request as ready for review April 24, 2026 16:31
@jeanduplessis jeanduplessis changed the title docs(kiloclaw): add referral program spec feat(kiloclaw): implement impact referrals Apr 24, 2026
Comment thread apps/web/src/lib/kiloclaw-referrals.ts Outdated
Comment thread apps/web/src/lib/impact-referral.ts
Comment thread apps/web/src/components/referrals/ImpactAdvocateReferralCard.tsx
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot Bot commented Apr 24, 2026

Code Review Summary

Status: 17 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 4
WARNING 13
SUGGESTION 0
Issue Details (click to expand)

Inline comments could not be posted for the latest incremental findings because GitHub rejected the review with unresolved paths/lines. They are listed under Other Observations.

CRITICAL

File Line Issue
apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts 220 Existing cloud-agent session actions validate membership in input.organizationId but forward only the session id, so a user's session from another org context can be operated through any org they belong to.
Other Observations (not in diff)

Issues found in changed or carried-forward code that could not receive inline comments:

File Line Issue
apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts 387 Legacy Enterprise provider_policy_mode / provider_deny_list settings are no longer enforced, so previously denied providers can become available again.
apps/web/src/app/api/integrations/linear/callback/route.ts 314 Duplicate Linear installs can overwrite/delete the existing owner's Chat SDK adapter credentials before the DB ownership check rejects the install.
services/cloud-agent-next/src/workspace.ts 643 Generic git clone failures can expose credentials from arbitrary HTTPS userinfo because sanitizeGitOutput only masks a few known username prefixes.
packages/db/src/migrations/0119_sad_katie_power.sql 234 The regenerated referral migration omits previous manual backfills for legacy affiliate touches, deleted-user tombstones, and already-applied reward redemptions.
packages/db/src/schema.ts 4062 agent_environment_profiles.created_by_user_id remains unsanitized for retained org-owned profiles during GDPR soft delete.
apps/web/src/app/api/integrations/google/connect/route.ts 40 The new user-facing onboarding calendar step links to a Google OAuth route that still requires adminOnly: true, blocking non-admin users.
apps/web/src/app/api/integrations/linear/callback/route.ts 252 Linear OAuth error telemetry logs the raw signed state token instead of a hash/redacted value.
apps/web/src/app/api/integrations/linear/callback/route.ts 274 Invalid-state telemetry redacts code separately but reintroduces the raw OAuth code through allParams.
apps/web/src/lib/bot/linear-link-token.ts 57 Malformed non-ASCII link-token signatures can make timingSafeEqual throw instead of returning null.
packages/db/src/schema-types.ts 459 OrganizationSettingsSchema drops legacy provider policy fields, so parse/write round trips can silently delete existing provider restrictions.
apps/web/src/routers/admin-kiloclaw-instances-router.ts 2989 reassociateVolume can target an instance that does not belong to the supplied userId, producing wrong-target storage mutation and misleading audit history.
services/auto-triage-infra/src/index.ts 79 The classification callback returns 202 before validation/state transition complete.
apps/mobile/src/components/kiloclaw/onboarding/notifications-step.tsx 73 Onboarding completes without waiting for push-token registration.
apps/web/src/lib/integrations/slack-service.ts 139 Suspended Slack integrations can still be resolved for event handling.
apps/web/src/app/api/integrations/slack/callback/route.ts 81 Raw Slack OAuth code can be included in allParams telemetry.
apps/web/src/app/api/integrations/slack/callback/route.ts 131 Duplicate Slack installs can mutate Chat SDK state before DB rejection.

Fix these issues in Kilo Cloud

Files Reviewed (incremental)
  • apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts - 1 issue
  • apps/web/src/app/api/integrations/linear/callback/route.ts - 3 issues
  • services/cloud-agent-next/src/workspace.ts - 1 issue
  • packages/db/src/migrations/0119_sad_katie_power.sql - 1 issue
  • apps/web/src/app/api/integrations/google/connect/route.ts - 1 issue
  • apps/web/src/lib/bot/linear-link-token.ts - 1 issue
  • packages/db/src/schema-types.ts - 1 issue
  • apps/web/src/routers/admin-kiloclaw-instances-router.ts - 1 issue
  • apps/web/src/app/(app)/claw/components/CalendarConnectStep.tsx
  • apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx
  • apps/web/src/app/api/openrouter/[...path]/route.ts
  • apps/web/src/lib/ai-gateway/embeddings/kilo-embedding-models.ts
  • apps/web/src/lib/integrations/linear-service.ts
  • apps/web/src/lib/bot/linear-adapter.ts
  • packages/db/src/migrations/meta/_journal.json
  • packages/db/src/schema.ts
  • packages/kiloclaw-instance-tiers/src/catalog.ts
  • services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
  • services/kiloclaw/src/providers/fly/index.ts
  • services/kiloclaw/src/providers/northflank/index.ts

Reviewed by gpt-5.5-2026-04-23 · 3,425,513 tokens

Comment thread services/kiloclaw-billing/src/lifecycle.ts Outdated
Comment thread apps/web/src/lib/user.server.ts
Comment thread apps/web/src/lib/impact-advocate.ts Outdated
Comment thread apps/web/src/lib/user.ts Outdated
Comment thread apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx
Comment thread packages/db/src/migrations/0117_ambiguous_mad_thinker.sql Outdated
Comment thread .specs/kiloclaw-referrals.md
Comment thread .specs/kiloclaw-referrals.md Outdated
jeanduplessis and others added 18 commits May 7, 2026 20:14
Co-authored-by: Rietie <job@kilocode.ai>
The beads task tracker writes per-agent state under .beads/ that should
not be checked in. Add it next to the existing agent-plan ignore.
Add a top-level AGENTS.md section and a nested apps/web/AGENTS.md so
agents working anywhere under apps/web read design.md and load the
kilo-design skill before touching components, routes, layouts, styling,
Storybook stories, copy, interaction states, responsive behaviour,
theming, or accessibility — even when the prompt does not explicitly
mention design.
Pin the Verified Access JWT contract: header MUST set kid to the Impact
Account SID, payload MUST contain a top-level user object, and the JWT
MUST be signed with the Impact Advocate Auth Token. Switch the
Advocate identity contract from Kilo user ID to plain user email for
both id and accountId so the identifier remains stable across our
internal user-ID rotations.
Wire the end-to-end Impact integration that Kilo's referral program
relies on:

- impact.ts / impact-advocate.ts / impact-affiliate-utils.ts /
  impact-referral.ts: Verified Access token signing, Register
  Participant payload construction, normalized email tombstoning,
  affiliate touch parsing/persistence, and locale/country resolution.
- impact-debug.ts: shared debug logger gated by
  IMPACT_ADVOCATE_DEBUG_LOGGING / IMPACT_REFERRAL_DEBUG_LOGGING so we
  never leak tokens, cookies, or full URLs in production logs.
- affiliate-events.ts: dedupe-key based attribution + parent-event
  enqueue with structured logging.
- ImpactIdentify.tsx: SHA-1-hash the customer email before calling
  window.ire('identify', ...), with debug logs around the retry path.
- getSignInCallbackUrl + after-sign-in/route.tsx: preserve im_ref,
  rsCode, rsShareMedium, rsEngagementMedium, _saasquatch, and utm_*
  through the sign-in callback so we can attribute on first auth.
- user.server.ts / user.ts: extract Impact tracking context from the
  callback URL cookie (with affiliate-touch suppression when an
  Advocate referral cookie is present) and forward it into user
  upsert; on signup, persist affiliate touches, referral touches, and
  queue an Advocate Register Participant call inside the transaction.

PII handling stays GDPR-friendly: email is the Advocate identifier per
the spec, and createDeletedUserEmailTombstone hashes it on
soft-delete. No tokens, cookies, or auth headers are logged.
Implement the server side of the KiloClaw referral program — the
attribution touches, referral records, and reward decisions that turn
an Impact Advocate touch into a free-month renewal extension.

- kiloclaw-referrals.ts: end-to-end lifecycle covering source-touch
  promotion at signup, referral creation gated by a 10-minute touch-
  capture grace window, reward eligibility checks against the current
  KiloClaw subscription, and reward application via
  insertKiloClawSubscriptionChangeLog. All operations use a system
  actor (actorType: 'system', actorId: 'kiloclaw-referrals') so the
  audit trail is unambiguous.
- kiloclaw-router.ts: tRPC procedures backing the referral surface —
  getReferralRewardSummary for the user's reward view, plus internal
  flows that the billing-side-effects route consumes.
- billing-side-effects/route.ts: webhook-driven entry point that
  applies pending rewards on subscription state transitions.
- services/kiloclaw-billing/lifecycle.ts: hook the referral
  application into the renewal lifecycle so rewards extend the
  current period rather than refunding cash.
- migration 0109_panoramic_vapor: introduce kiloclaw_attribution_touches,
  kiloclaw_referrals, kiloclaw_referral_rewards, and
  kiloclaw_referral_reward_decisions with the indexes the lifecycle
  queries need; enable pgcrypto for tombstone hashing; backfill
  existing attribution touches.
- empty-database.ts: keep the dev reset path schema-aware so the new
  tables drop cleanly.

Per AGENTS.md, this introduces new PII-bearing tables; the GDPR
soft-delete tombstoning lives in lib/impact-referral.ts and is wired
through user.ts in the previous commit.
Add an internal admin surface for inspecting referral attribution and
reward state. Surfaces the touch chain, referral records, reward
decisions, and Advocate registration status keyed by user, tracking
ID, or referral code — what we'll need when debugging missing rewards
or duplicate Advocate registrations.

- /admin/kiloclaw-referrals page + KiloclawReferralsInvestigation
  component (read-only).
- adminKiloclawReferralsRouter: typed tRPC procedures over the same
  data the user-facing reward summary consumes, with admin-only
  guards inherited from adminProcedure.
- Wire the router into adminRouter and add a sidebar entry.
Add the user-facing referral surface and the embeddable widget that
authenticates the current user against Impact Advocate's Verified
Access contract.

- /claw/refer page: SetPageTitle + ReferralRewardStatusCard + the
  Impact Advocate widget. Loads getReferralRewardSummary so the
  page shows pending vs applied rewards, referred-people totals, and
  a 'Start/Reactivate' CTA when there are pending rewards waiting on
  an active subscription.
- ReferralRewardStatusCard: composed status card for the totals,
  referred-people list, and reward history; covered by unit tests
  for the empty, pending, and applied states.
- ReferralRewardsSummary: shared component reused by the dashboard
  active-subscription card and the KiloClaw detail page.
  - variant="card" | "section" so the same component renders flat
    inside another card (avoiding the Kilo nested-card anti-pattern)
    and standalone as a sibling card on the detail page.
  - Badge variant="new" pill for 'X free months applied'.
  - ArrowRight between renewal-moved dates with tabular-nums.
  - aria-live="polite" so newly applied rewards announce after a
    refetch; Kilo-voice copy ('Free months push your renewal date
    out.', 'No rewards yet. Refer a friend to earn a free month.');
    role labels addressed to the user ('Reward for referring',
    'Welcome reward').
- ImpactAdvocateReferralCard: move under components/referrals/ and
  delete the old profile copy. Profile page no longer renders the
  card — referrals now live on /claw/refer.
- PersonalAppSidebar: 'Refer & Earn' menu item under the KiloClaw
  group with a 'NEW' badge and 'Get 1 Month Free' subtitle.
- SidebarMenuList: optional subtitle, badge, and className on
  MenuItem so the sidebar can render the new two-line entry without
  cloning the primitive.
- billing-types: extend ClawBillingStatus with the rewards summary
  shape consumed by the new surfaces, with parity tests.
Standardise the /claw subscription tab and /subscriptions/kiloclaw/:id
detail page on shared Kilo primitives so they read as one product.
Driven by a kilo-design review against reference/kilo-brand.md.

Shared primitives:
- DetailRow: label-over-value row with optional numeric (tabular-nums).
- useKiloClawBillingQueries: single source of truth for invalidating
  every query keyed off a KiloClaw instance after a state-changing
  mutation, removing the duplicate invalidation block that existed in
  both surfaces.

SubscriptionCard (/claw):
- Replace bespoke tinted-fill rounded-xl shells (emerald/amber/blue/
  red/indigo) with one neutral KiloClawCardShell built on Card +
  CardTitle + SubscriptionStatusBadge. Status is communicated by the
  badge plus context-specific Alert variants, not background colour
  — restoring brand-accent discipline per kilo-brand.md.
- Drop the hand-rolled PaymentSourceBadge (and the indigo palette);
  payment source now lives in a DetailRow via formatPaymentSummary().
- DetailRow grid for Plan / Next billing / Payment source with
  numeric on dates and prices; new explicit 'Auto-renew' row for
  commit plans replaces the parenthetical aside.
- One primary CTA per non-resting state (Reactivate, Keep Stripe
  billing, Update payment / Add credits); active resting state
  stays neutral with destructive-styled outline 'Cancel subscription'
  pushed to the right.
- All state-changing actions go through an AlertDialog confirmation,
  matching the detail page so the dashboard and detail flows agree.
- aria-busy on async buttons, aria-hidden on decorative icons,
  Loader2 announced via aria-busy not visual spinner alone.
- Conversion prompt and 'switching scheduled' notes use Alert
  variants instead of nested tinted boxes.

KiloClawDetail (/subscriptions/kiloclaw/:id):
- Drop the inline private DetailRow in favour of the shared one;
  numeric on Price, Next renewal, Commit ends, Trial ends,
  Suspended at, Destruction deadline.
- Replace the hand-rolled bg-blue-500/10 status note with
  Alert variant="notice".
- refreshData replaced by the shared useInvalidateKiloClawBilling
  hook.
- Reactivate promoted to primary; sentence-case labels; aria-busy
  on AlertDialogAction during pending state.

ReferralRewardsSummary now embeds as variant="section" inside the
dashboard card (no nested-card anti-pattern) and as variant="card"
on the standalone detail page.

Crab-icon sweep across the surrounding dashboard so emoji 🦀 is
replaced by KiloCrabIcon at size-5 in BillingWrapper's
EarlybirdActiveCard and both EarlybirdCard variants. The remaining 🦀
literal in apps/web/src/app/(app)/claw/hooks/useOnboardingSaves.test.ts
is botEmoji data (user-configured chat-agent emoji) and is unrelated
to the Kilo logo system.
- start-tunnel.ts: split TUNNEL_HOSTNAME into per-service hostnames
  (TUNNEL_APP_HOSTNAME, TUNNEL_KILOCLAW_HOSTNAME,
  TUNNEL_KILOCHAT_HOSTNAME) so the dev tunnel can route the web app,
  KiloClaw, and Kilo Chat to distinct hostnames; preserve the legacy
  single-hostname fallback. Also pull from .env.local in addition to
  .dev.vars and use a quoted-identifier helper when rewriting env
  files, so values containing special characters survive the round
  trip.
- dev/seed/app/add-credits.ts: standalone seed script to top up a
  user's credit balance for end-to-end referral reward testing.
- dev/seed/kiloclaw/fake-instance.ts: provision a fake KiloClaw
  instance with a chosen plan/payment source so referral lifecycle
  paths (signup, renewal, reward application) can be exercised
  locally without standing up real hosting.
- dev/seed/lib/kiloclaw-referrals.ts: minor adjustments to align
  with the lifecycle changes in apps/web/src/lib/kiloclaw-referrals.ts.
- services/kiloclaw DEVELOPMENT.md / README.md /
  .dev-start.conf.example: document the new tunnel hostnames and
  seed scripts.
Tighten the manual-test loop for the Impact integration. Previously two
independent gates (IMPACT_ADVOCATE_DEBUG_LOGGING and IMPACT_REFERRAL_DEBUG)
controlled overlapping log paths, and several outbound calls dropped the
response on the floor — making it hard to tell from logs whether a payload
actually landed at Impact.

- impact-debug.ts: single isImpactDebugLoggingEnabled() gate that honors
  NODE_ENV=development, IMPACT_REFERRAL_DEBUG=true, and the legacy
  IMPACT_ADVOCATE_DEBUG_LOGGING flag. Add truncateForLog(body, 500) so
  Impact response bodies (which Impact uses to convey rejection reasons)
  can be safely logged without flooding output.
- impact-advocate.ts: route logImpactAdvocateDebug through the unified
  logger and log the Register Participant response (url, ok, statusCode,
  truncated responseBody) plus a network-error branch. Authorization
  header continues to be redacted as 'not_logged'; cookies stay redacted
  via getDebuggableRegisterParticipantPayload.
- impact.ts: log the full conversion request URL up front (was path-only),
  log the response body and error message on failure for
  sendImpactConversionPayload, and add request + raw-result logging
  around resolveImpactSubmissionUri (previously had zero logging).
- impact-advocate.test.ts: spy on console.log instead of console.warn
  to match the unified logger's output channel; existing redaction
  assertions are unchanged.
… User

Live registration was returning 404 from Impact's Kong gateway with
'no Route matched with those values'. The integration spec in fact
documents the SaaSquatch Upsert User endpoint, not a generic Impact
participants endpoint:

  PUT https://app.referralsaasquatch.com/api/v1/{tenantAlias}\
      /open/account/{accountId}/user/{userId}

Per the program spec, accountId and userId are both the user's plain
email — these are URL-encoded because the path segment contains '@'.

Changes:
- IMPACT_ADVOCATE_API_BASE_URL env var (default
  https://app.referralsaasquatch.com) so a sandbox/proxy host can be
  injected without a code change.
- getImpactAdvocateRegisterParticipantUrl now takes the payload so it
  can pull accountId/userId into the path; trims trailing slashes on
  the base URL and url-encodes the segments.
- HTTP method flipped from POST to PUT, matching SaaSquatch's
  upsert-user verb.
- normalizeAdvocateLocale converts BCP 47 'en-US' (what we get from
  Accept-Language) to SaaSquatch's required 'en_US' format. Applied
  at payload-build time so the persisted attempt body matches the
  wire format on retry.
- Tests updated to assert the new URL/method/locale; all 14 advocate
  + referral tests pass.
Retry hit a second SaaSquatch rejection:
  400 INVALID_JSON_REQUEST 'Unrecognized field programId'

Per the Upsert User integration spec, SaaSquatch validates the body
against a strict allow-list (id, accountId, email, cookies +
firstName, lastName, locale, countryCode, segments, customFields).
Any extra field is rejected with INVALID_JSON_REQUEST.

The same retry also re-sent locale='en-US' from the original persisted
attempt, because dispatch reads request_payload off disk; build-time
locale normalisation never reaches retried rows.

Both problems share a fix: sanitize the payload at the moment we hit
the wire instead of at build time only.

- ImpactAdvocateRegisterParticipantPayload type drops programId and
  adds firstName, lastName, segments, customFields per the spec.
- buildImpactAdvocateRegisterParticipantPayload no longer sets
  programId.
- New sanitizeRegisterParticipantPayloadForWire allow-list filter
  runs inside sendImpactAdvocateRegisterParticipantPayload right
  before fetch. It strips unknown fields (e.g. legacy programId)
  and re-runs locale normalisation (en-US -> en_US) so previously
  persisted attempts retry cleanly without a data migration.
- isImpactAdvocateRegisterParticipantPayload no longer requires
  programId; it asserts only the SaaSquatch-required fields and
  tolerates extras (which the sanitiser will drop).
- New regression test covers the exact retry shape: legacy programId,
  BCP 47 locale, and a junk extra field; sanitiser must produce
  SaaSquatch-acceptable JSON.

Also flips the persisted attempt + participant rows from 'failed'
back to 'queued'/'pending' (done out-of-band via psql at the user's
request) so the next cron tick exercises the fixed path.
jeanduplessis and others added 14 commits May 7, 2026 20:14
…esolve

The Kilo-side advocate-resolution lookup at kiloclaw-referrals.ts:275
joins kiloclaw_attribution_touches.rs_code (the rsCode parsed from the
referee's referral URL) against
impact_advocate_participants.opaque_referral_identifier. In production
that join could never match because the dispatcher never wrote the
SaaSquatch-issued referral code anywhere on Kilo's side: it threw away
the response body after using it for state transitions. The only rows
where the lookup ever succeeded were synthetic seed rows that populated
both columns with matching UUIDs.

End-to-end manifestation: referrerUserId came back null on every real
paid conversion, silently undercounting attribution on the Kilo side
even when SaaSquatch had it correct.

Fix:
- impact-advocate.ts adds extractAdvocateReferralCodeFromUpsertResponse
  (pure parser, returns the program-scoped code or null on any malformed
  / missing input) and getImpactAdvocateProgramId() so callers don't
  duplicate the env-default fallback.
- dispatchImpactAdvocateRegistrationAttemptById extracts the code on a
  successful upsert and includes it in the participant UPDATE inside
  the same transaction. A pre-check guards the
  UQ_impact_advocate_participants_opaque_referral_identifier unique
  constraint: if another participant already holds the candidate code
  (vanishingly unlikely under SaaSquatch's per-tenant uniqueness
  guarantee, but still constraint-protected), the new row keeps its
  prior identifier and the rest of the success state is still recorded
  — so we don't loop forever in retry on a real collision.
- New rule 50 in .specs/kiloclaw-referrals.md captures the requirement;
  rules 50-118 in the body shifted to 51-119. Failure-mode rules and
  changelog untouched.
- Test coverage: unit tests for the parser (program-scoped extraction,
  malformed input, missing program, non-string codes) plus an
  integration test that asserts the participant row receives the parsed
  code on success, and a second integration test that asserts the
  conflict-skip path leaves both rows valid.

Existing rows on disk were backfilled out-of-band by reading
response_payload->>'responseBody' and copying referralCodes['51699']
into opaque_referral_identifier (skipping rows that would violate the
unique constraint). Sample SQL block lives in the prior message
exchange; not a migration because this is dev-data correction, not a
schema change.
… Access token route

Close the architectural gap that left advocate-only Kilo users (anyone
who never came through the referee /_saasquatch cookie path)
undiscoverable to the conversion lifecycle.

Before:
- /api/impact-advocate/token issued the Verified Access JWT and
  generated a Kilo-side random UUID, writing it as both
  referral_codes.code (internal Kilo system) AND
  impact_advocate_participants.opaque_referral_identifier (Impact
  integration table). The two tables were conflated.
- The SaaSquatch widget separately upserted the user via the JWT and
  issued a real referral code (e.g. REFERRER5616), but Kilo never
  learned what code SaaSquatch had assigned.
- Inbound referee touches with rs_code=REFERRER5616 could never match
  participants.opaque_referral_identifier=<UUID>, so the conversion
  lifecycle resolved referrerUserId=null and silently undercounted
  attribution every time a real advocate's link was used.
- Worse, the UUID overwrite ran on every /claw/refer page load,
  clobbering any SaaSquatch code the dispatcher had managed to persist.

After:
- queueImpactAdvocateSelfRegistration (new) queues an Upsert User
  attempt for the advocate with empty cookies (no inbound attribution)
  and dedupe key ('impact-advocate-self-registration', userId), so
  repeat /claw/refer visits don't stack attempts. Skips queueing once
  the participant is already registered with a code.
- /api/impact-advocate/token now (a) keeps the internal referral_codes
  UUID (separate, internal Kilo system, untouched), (b) drops the
  opaque_referral_identifier write so the dispatcher's SaaSquatch code
  is never clobbered, and (c) calls queueImpactAdvocateSelfRegistration
  with locale/country derived from request headers.
- The dispatcher (already fixed in e4c5b14) parses
  referralCodes[programId] from the SaaSquatch upsert response and
  writes it to participants.opaque_referral_identifier. The same code
  path now serves both referee and advocate-only registrations.
- Spec rule 11 (new) makes server-side advocate registration on
  Verified Access token issuance a hard requirement; cross-references
  rule 51 (the persistence rule). Rules 11-119 shifted to 12-120 to
  make room.

Tests: queue + dispatch happy path, idempotency across repeat calls,
skip-when-already-registered. All 4779 tests pass (was 4776; +3 new).

The next /claw/refer load by an advocate-only user will queue an
Upsert User attempt; the cron will dispatch within ~60s and persist
their SaaSquatch code so the conversion lifecycle can resolve them as
the referrer for any future referee that converts via their share
link.
Logic & data:
- softDeleteUser falls back to google_user_email when normalized_email
  is NULL so pre-0090 users still get a deletion tombstone before
  anonymization destroys the email (added regression test).
- applyReferralRewardById back-fills expires_at = earned_at + 12 months
  when a Referrer reward transitions Earned -> Pending, mirroring the
  conversion-time invariant in spec rule 66.

Type safety:
- dispatchImpactConversionReportById validates report.request_payload
  with a new isImpactConversionPayload predicate instead of an
  unchecked cast; invalid_request_payload is reported separately
  from missing_request_payload.
- kiloclaw-referral-eligibility route.test.ts replaces 'as never'
  casts with typed adminUserFixture / subscriptionFixture helpers
  using Partial<T> -> T (structural, not double-cast through unknown).

Style:
- Remove logImpactAdvocateDebug thin wrapper; call sites use
  logImpactReferralDebug directly.
- Consolidate hand-written tracking-param checks in
  getSignInCallbackUrl.ts into the existing UTM loop via a single
  ordered trackingParams array (test order preserved).
- Drop redundant params.result.ok && ... conjuncts inside an
  already-narrowed branch in persistImpactConversionReportResult.
- Remove getNestedObjectProperty one-line alias.
- ImpactIdentify.tsx uses logImpactReferralDebug helper instead of
  reimplementing the prefix and dev gate inline.
- Drop unused default React imports from
  KiloclawReferralsInvestigation.tsx and ReferralRewardsSummary.tsx.
@jeanduplessis jeanduplessis merged commit 25d182a into main May 7, 2026
43 checks passed
@jeanduplessis jeanduplessis deleted the impact-refferals branch May 7, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants