orchestration.ts
quota-rebalance.ts
output
// ── Orchestration Card — OC-01
// Runs after Q7 (open feedback) — final logic pass before closing
// Touches 4 external/internal systems in a single atomic block
import { interview, session, quota } from '@r2/runtime';
import { RewardEngine } from '@r2/integrations/rewards';
import { CRMClient } from '@r2/integrations/crm';
// ── Step 1: Pipe answers into derived variables ───────────────────────────
const age = session.get('S1_age');
const nps = session.get('Q3_nps');
const segment = session.get('segment'); // set by Intelligence Gate
const topBrand = session.get('Q1_selected')[0]; // first brand they selected
const spendBand = age < 30 ? 'emerging' : age < 40 ? 'core' : 'established';
// Derived variables — available to remaining cards and analysis layer
session.set('derived_spendBand', spendBand);
session.set('derived_topBrand', topBrand);
session.set('derived_npsSegment', nps >= 9 ? 'promoter' : nps >= 7 ? 'passive' : 'detractor');
session.set('derived_fullSegment', `${spendBand}_${segment}`);
// ── Step 2: Dispatch reward — amount varies by segment ────────────────────
const rewardAmount = {
lapsed_promoter_frustrated: 750, // extra points — retain at-risk
rising_advocate: 500,
standard: 350,
}[segment] ?? 350;
const reward = await RewardEngine.dispatch({
panelId: session.panelId,
points: rewardAmount,
reason: 'survey_complete',
studyId: session.studyId,
idempotencyKey: `${session.sessionId}-reward`, // safe to retry
});
// ── Step 3: Write enriched segment back to CRM ───────────────────────────
await CRMClient.upsert({
panelId: session.panelId,
fields: {
luxurySegment: session.get('derived_fullSegment'),
lastNPS: nps,
topBrand: topBrand,
surveyDate: new Date().toISOString(),
},
});
// ── Step 4: Mutate remaining flow based on everything we now know ─────────
if (segment === 'lapsed_promoter_frustrated' && nps <= 5) {
// Replace final NPS card with a softer brand relationship question
// — don't ask for NPS twice, they're already telling us they're unhappy
interview.replaceCard('Q8', {
type: 'single_choice',
content: { text: `What would bring you back to ${topBrand}?` },
options: [
{ value: 'price', label: 'A more accessible price point' },
{ value: 'results', label: 'Seeing better results' },
{ value: 'trust', label: 'Rebuilding trust in the brand' },
{ value: 'nothing', label: "I've moved on", anchor: true },
],
});
}
// ── Step 5: Quota cell self-correction ───────────────────────────────────
// (see quota-rebalance.ts tab)
await quota.rebalance(session);
// quota-rebalance.ts — called by Orchestration Card at survey end
// Corrects cell assignment based on answers, not just screening data
export async function rebalance(session: Session) {
const screenedAge = session.get('S1_age');
const selfReported = session.get('Q_spend_frequency'); // 'daily'|'weekly'|'rarely'
const currentCell = session.get('quotaCellId');
// Respondent was assigned Gen Z cell at entry (age 26)
// but their spend behaviour matches the "core Millennial" profile
const behavioural = selfReported === 'daily' && screenedAge < 29;
if (behavioural && currentCell === 'gen_z_25_28') {
const coreCell = await quota.getCell('millennial_core_spender');
if (coreCell.current < coreCell.target) {
// Cell has room — move them
await quota.reassign(session.sessionId, 'millennial_core_spender');
session.set('quotaReassigned', true);
session.set('quotaReassignReason', 'behavioural_override');
} else {
// Cell full — flag as over-quota behavioural match (still completes)
session.set('quotaFlag', 'over_quota_behavioural');
}
}
// Write final quota snapshot to ClickHouse via Queues
await quota.snapshot(session);
}
// What this card does — all atomic, all in a single DO execution turn
1. Piped variables written to session:
session.derived_spendBand = 'core'
session.derived_topBrand = 'La Mer'
session.derived_npsSegment = 'detractor'
session.derived_fullSegment = 'core_lapsed_promoter_frustrated'
2. Reward dispatched:
RewardEngine → 750 pts → panel member #panelId
idempotency key prevents double-dispatch on retry
3. CRM updated:
luxurySegment, lastNPS, topBrand, surveyDate upserted
4. Card Q8 replaced in-flight:
NPS card swapped for "What would bring you back to La Mer?"
Respondent never sees the replaced card — flow just continues
5. Quota rebalanced:
Session reassigned: gen_z_25_28 → millennial_core_spender
Reason: behavioural_override (daily spend + age 26)
Snapshot written to ClickHouse via CF Queues