feat(kiloclaw): implement impact referrals#2643
Merged
jeanduplessis merged 32 commits intomainfrom May 7, 2026
Merged
Conversation
c67c1e3 to
5671d31
Compare
Contributor
Code Review SummaryStatus: 17 Issues Found | Recommendation: Address before merge Overview
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
Other Observations (not in diff)Issues found in changed or carried-forward code that could not receive inline comments:
Fix these issues in Kilo Cloud Files Reviewed (incremental)
Reviewed by gpt-5.5-2026-04-23 · 3,425,513 tokens |
b1ba53b to
03f33d1
Compare
acaf8ab to
2505b51
Compare
pandemicsyn
approved these changes
May 7, 2026
367403e to
51d2474
Compare
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.
…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.
51d2474 to
adf42b4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.Verification
Manual verification previously recorded for this PR:
.specs/kiloclaw-referrals.md,.specs/kiloclaw-affiliates.md, and.specs/kiloclaw-billing.md.referral_missing_configuration, a failed Impact report, and no granted rewards.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
.specs/kiloclaw-affiliates.md; the old.specs/impact-affiliate-tracking.mdname no longer exists on this branch.packages/db/src/migrations/0117_ambiguous_mad_thinker.sql.public, so Drizzle bootstrap state is cleared correctly.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