/*
RFC-0188: Visitor Sales Funnel state-machine contracts. The PLATFORM owns the canonical
funnel stage catalog, the typed event model, and the transition graph; UChat is the conversation
runtime that renders the funnel and REQUESTS transitions — it never defines the graph, owns no
pricing, and writes no canonical state. Make.com is excluded everywhere in the funnel path. Pure
types + constants + pure helpers — no I/O, no astro:env, no vendor SDK.
- Do not let a chat vendor own the graph — UChat requests transitions; this module is the authority.
- Do not reference Make.com or carry any legacy UChat goto/stage string as architecture.
- Do not import astro:env or a vendor SDK — callers inject persistence.
- RFC-0188 Phase 2 (contract): initial funnel stage/event/transition contracts.
- RFC-0219: add FUNNEL_SYSTEM_TRIGGERS, FunnelTransitionTrigger, FUNNEL_TRANSITION_TRIGGERS — trigger overlay for the state-chart generator.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
/** Current canonical funnel contract version. Bump on a breaking stage/event change. */
export const FUNNEL_VERSION = "1.0.0";
// ---------------------------------------------------------------------------
// Stage catalog (closed, platform-owned)
// ---------------------------------------------------------------------------
/**
* Closed catalog of canonical funnel stages (RFC-0188). Names describe the
* PLATFORM process, never UChat GUI nodes or Pipedrive stage ids. UChat mirrors
* the current stage (e.g. in a custom field synced from Lagebild) but the catalog
* is owned here. Extend only by updating this array AND the transition graph.
*/
export const VISITOR_FUNNEL_STAGES = [
"new_session",
"privacy_acknowledged",
"intent_selected",
"organization_selected",
"qualification_priority",
"qualification_company",
"qualification_service",
"qualification_region",
"offer_presented",
"payment_pending",
"payment_confirmed",
"start_choice_pending",
"start_deferred",
"buyer_type_pending",
"b2b_start_consent_pending",
"b2c_withdrawal_consent_pending",
"start_approved",
"legal_data_requested",
"materials_requested",
"production_ready",
"change_balance_checked",
"change_payment_pending",
"change_description_requested",
"operator_review",
"won",
"lost",
] as const;
export type VisitorFunnelStage = (typeof VISITOR_FUNNEL_STAGES)[number];
/** The single entry stage every visitor session starts at. */
export const FUNNEL_ENTRY_STAGE: VisitorFunnelStage = "new_session";
/** Terminal stages — no outbound transition is allowed from these. */
export const FUNNEL_TERMINAL_STAGES: readonly VisitorFunnelStage[] = ["won", "lost"];
// ---------------------------------------------------------------------------
// Event model (typed business payloads on top of IntegrationEvent)
// ---------------------------------------------------------------------------
/** Closed catalog of typed funnel event kinds (RFC-0188). */
export const VISITOR_FUNNEL_EVENT_KINDS = [
"session.started",
"privacy.acknowledged",
"language.selected",
"intent.selected",
"organization.selected",
"qualification.answered",
"offer.selected",
"payment.link.requested",
"payment.confirmed",
"start.choice.selected",
"buyer.type.selected",
"legal.consent.recorded",
"material.submitted",
"change.requested",
"operator.note.added",
] as const;
export type VisitorFunnelEventKind = (typeof VISITOR_FUNNEL_EVENT_KINDS)[number];
/** Visitor intents — the top-level branch chosen after the welcome. */
export const VISITOR_FUNNEL_INTENTS = ["create_site", "change_site", "ask_question"] as const;
export type VisitorFunnelIntent = (typeof VISITOR_FUNNEL_INTENTS)[number];
/**
* Allowed funnel event SOURCES. UChat is the conversation runtime; Stripe is the
* payment-confirmation source (direct webhook, no Make.com); operator covers manual
* actions; send-message is the first-party contact form. Make.com is never a source.
*/
export const VISITOR_FUNNEL_SOURCES = ["uchat", "stripe", "operator", "send-message"] as const;
export type VisitorFunnelSource = (typeof VISITOR_FUNNEL_SOURCES)[number];
/** Buyer type — drives the legal-consent branch (B2B start vs B2C withdrawal). */
export const VISITOR_BUYER_TYPES = ["business", "consumer"] as const;
export type VisitorBuyerType = (typeof VISITOR_BUYER_TYPES)[number];
/**
* Typed funnel event payload (RFC-0188). Carried as the `payload` of a normalized
* IntegrationEvent so the existing inbound/dedup machinery (RFC-0176) is reused.
* Offer snapshots are captured at selection time so a later offer.md change never
* re-prices a historical deal; legal-consent payloads are append-only.
*/
export interface VisitorFunnelEventPayload {
funnelVersion: string;
eventKind: VisitorFunnelEventKind;
stage?: VisitorFunnelStage;
previousStage?: VisitorFunnelStage;
intent?: VisitorFunnelIntent;
locale: string;
contact?: { name?: string; email?: string; phone?: string };
organization?: { id?: string; name?: string };
qualification?: {
priority?: "new_customers" | "professional_presence" | "online_presence" | "all";
companyName?: string;
serviceOrIndustry?: string;
region?: string;
};
offer?: {
plan?: "digital_foundation_monthly" | "digital_foundation_yearly";
growthModules?: string[];
/** Captured at selection time (deal-time snapshot) — never recomputed later. */
priceSnapshot?: Record;
};
legal?: {
buyerType?: VisitorBuyerType;
startBeforeWithdrawalPeriod?: boolean;
withdrawalExpiryAcknowledged?: boolean;
};
changeRequest?: {
includedChangesAvailable?: number;
description?: string;
};
}
// ---------------------------------------------------------------------------
// Transition graph (platform-owned authority)
// ---------------------------------------------------------------------------
/**
* The valid transition graph (RFC-0188). UChat may REQUEST a transition; this graph
* is the authority that validates it. `lost` (abandon) is reachable from every
* non-terminal stage. Free-question side-conversations are NOT transitions — they
* are an orthogonal resume mechanism (the visitor returns to the SAME stage), so
* they intentionally do not appear here.
*/
export const FUNNEL_TRANSITIONS: Readonly<
Record
> = {
new_session: ["privacy_acknowledged", "lost"],
privacy_acknowledged: ["intent_selected", "lost"],
// create_site → organization_selected; change_site → change_balance_checked.
intent_selected: ["organization_selected", "change_balance_checked", "lost"],
organization_selected: ["qualification_priority", "lost"],
qualification_priority: ["qualification_company", "lost"],
qualification_company: ["qualification_service", "lost"],
qualification_service: ["qualification_region", "lost"],
qualification_region: ["offer_presented", "lost"],
offer_presented: ["payment_pending", "lost"],
payment_pending: ["payment_confirmed", "lost"],
payment_confirmed: ["start_choice_pending", "lost"],
// start now → buyer_type_pending; in 14 days → start_deferred (keeps the place).
start_choice_pending: ["start_deferred", "buyer_type_pending", "lost"],
start_deferred: ["buyer_type_pending", "lost"],
buyer_type_pending: ["b2b_start_consent_pending", "b2c_withdrawal_consent_pending", "lost"],
b2b_start_consent_pending: ["start_approved", "lost"],
b2c_withdrawal_consent_pending: ["start_approved", "lost"],
start_approved: ["legal_data_requested", "lost"],
legal_data_requested: ["materials_requested", "lost"],
materials_requested: ["production_ready", "lost"],
production_ready: ["operator_review", "won", "lost"],
// Change journey: balance check → (no changes left) pay → describe, or describe directly.
change_balance_checked: ["change_payment_pending", "change_description_requested", "lost"],
change_payment_pending: ["change_description_requested", "lost"],
change_description_requested: ["operator_review", "production_ready", "won", "lost"],
operator_review: ["won", "lost", "production_ready"],
won: [],
lost: [],
};
const STAGE_SET: ReadonlySet = new Set(VISITOR_FUNNEL_STAGES);
/** True when `value` is a canonical funnel stage. */
export function isValidFunnelStage(value: string): value is VisitorFunnelStage {
return STAGE_SET.has(value);
}
/** True when `to` is a declared next stage of `from` in the transition graph. */
export function canTransition(from: VisitorFunnelStage, to: VisitorFunnelStage): boolean {
return FUNNEL_TRANSITIONS[from]?.includes(to) ?? false;
}
/** The declared next stages of `from` (empty for terminal stages). */
export function nextStages(from: VisitorFunnelStage): readonly VisitorFunnelStage[] {
return FUNNEL_TRANSITIONS[from] ?? [];
}
/**
* Every stage reachable from `start` (default: the entry stage) by following the
* transition graph. Used by funnel.stage.validate to prove no canonical stage is
* stranded (unreachable) from the funnel entry.
*/
export function reachableStages(
start: VisitorFunnelStage = FUNNEL_ENTRY_STAGE,
): ReadonlySet {
const seen = new Set();
const queue: VisitorFunnelStage[] = [start];
while (queue.length > 0) {
const stage = queue.shift()!;
if (seen.has(stage)) continue;
seen.add(stage);
for (const next of FUNNEL_TRANSITIONS[stage] ?? []) {
if (!seen.has(next)) queue.push(next);
}
}
return seen;
}
// ---------------------------------------------------------------------------
// Trigger overlay (RFC-0219)
// ---------------------------------------------------------------------------
/**
* Reserved non-funnel-event triggers for abandon / operator / system-advance edges.
* `system.timeout` covers every unforced transition to `lost` (visitor abandons the
* conversation). `system.advance` covers automatic state advances with no explicit
* visitor input. `operator.won` / `operator.lost` are operator-initiated closures.
*/
export const FUNNEL_SYSTEM_TRIGGERS = [
"system.timeout",
"system.advance",
"operator.won",
"operator.lost",
] as const;
export type FunnelSystemTrigger = (typeof FUNNEL_SYSTEM_TRIGGERS)[number];
/** A trigger label: either a typed funnel event kind or a reserved system trigger. */
export type FunnelTransitionTrigger = VisitorFunnelEventKind | FunnelSystemTrigger;
/**
* One entry per directed edge in `FUNNEL_TRANSITIONS` (RFC-0219). Labels each
* `(from, to)` pair with exactly one trigger. The bijection invariant — checked by
* `funnel.statechart.validate` — keeps this overlay and the transition map in exact
* lock-step: the edge sets must be equal and every `on` must be a valid trigger.
*
* Ordering follows `FUNNEL_TRANSITIONS` key order, then target order within each key,
* so the generator produces a deterministic document that can be drift-guarded
* byte-for-byte.
*/
export const FUNNEL_TRANSITION_TRIGGERS: ReadonlyArray<{
from: VisitorFunnelStage;
to: VisitorFunnelStage;
on: FunnelTransitionTrigger;
}> = [
{ from: "new_session", to: "privacy_acknowledged", on: "privacy.acknowledged" },
{ from: "new_session", to: "lost", on: "system.timeout" },
{ from: "privacy_acknowledged", to: "intent_selected", on: "intent.selected" },
{ from: "privacy_acknowledged", to: "lost", on: "system.timeout" },
// create_site branch → org selection; change_site branch → change balance check.
{ from: "intent_selected", to: "organization_selected", on: "organization.selected" },
{ from: "intent_selected", to: "change_balance_checked", on: "change.requested" },
{ from: "intent_selected", to: "lost", on: "system.timeout" },
{ from: "organization_selected", to: "qualification_priority", on: "qualification.answered" },
{ from: "organization_selected", to: "lost", on: "system.timeout" },
{ from: "qualification_priority", to: "qualification_company", on: "qualification.answered" },
{ from: "qualification_priority", to: "lost", on: "system.timeout" },
{ from: "qualification_company", to: "qualification_service", on: "qualification.answered" },
{ from: "qualification_company", to: "lost", on: "system.timeout" },
{ from: "qualification_service", to: "qualification_region", on: "qualification.answered" },
{ from: "qualification_service", to: "lost", on: "system.timeout" },
{ from: "qualification_region", to: "offer_presented", on: "qualification.answered" },
{ from: "qualification_region", to: "lost", on: "system.timeout" },
{ from: "offer_presented", to: "payment_pending", on: "offer.selected" },
{ from: "offer_presented", to: "lost", on: "system.timeout" },
{ from: "payment_pending", to: "payment_confirmed", on: "payment.confirmed" },
{ from: "payment_pending", to: "lost", on: "system.timeout" },
// After Stripe confirms payment the platform auto-advances to the start-choice screen.
{ from: "payment_confirmed", to: "start_choice_pending", on: "system.advance" },
{ from: "payment_confirmed", to: "lost", on: "system.timeout" },
// start now → buyer_type_pending; in 14 days → start_deferred.
{ from: "start_choice_pending", to: "start_deferred", on: "start.choice.selected" },
{ from: "start_choice_pending", to: "buyer_type_pending", on: "start.choice.selected" },
{ from: "start_choice_pending", to: "lost", on: "system.timeout" },
{ from: "start_deferred", to: "buyer_type_pending", on: "start.choice.selected" },
{ from: "start_deferred", to: "lost", on: "system.timeout" },
{ from: "buyer_type_pending", to: "b2b_start_consent_pending", on: "buyer.type.selected" },
{
from: "buyer_type_pending",
to: "b2c_withdrawal_consent_pending",
on: "buyer.type.selected",
},
{ from: "buyer_type_pending", to: "lost", on: "system.timeout" },
{ from: "b2b_start_consent_pending", to: "start_approved", on: "legal.consent.recorded" },
{ from: "b2b_start_consent_pending", to: "lost", on: "system.timeout" },
{
from: "b2c_withdrawal_consent_pending",
to: "start_approved",
on: "legal.consent.recorded",
},
{ from: "b2c_withdrawal_consent_pending", to: "lost", on: "system.timeout" },
// After both consent branches reach start_approved, the platform auto-advances.
{ from: "start_approved", to: "legal_data_requested", on: "system.advance" },
{ from: "start_approved", to: "lost", on: "system.timeout" },
{ from: "legal_data_requested", to: "materials_requested", on: "material.submitted" },
{ from: "legal_data_requested", to: "lost", on: "system.timeout" },
{ from: "materials_requested", to: "production_ready", on: "material.submitted" },
{ from: "materials_requested", to: "lost", on: "system.timeout" },
{ from: "production_ready", to: "operator_review", on: "operator.note.added" },
{ from: "production_ready", to: "won", on: "operator.won" },
{ from: "production_ready", to: "lost", on: "system.timeout" },
// Change journey: no credits left → payment link; credits available → describe directly.
{
from: "change_balance_checked",
to: "change_payment_pending",
on: "payment.link.requested",
},
{ from: "change_balance_checked", to: "change_description_requested", on: "system.advance" },
{ from: "change_balance_checked", to: "lost", on: "system.timeout" },
{ from: "change_payment_pending", to: "change_description_requested", on: "payment.confirmed" },
{ from: "change_payment_pending", to: "lost", on: "system.timeout" },
{ from: "change_description_requested", to: "operator_review", on: "change.requested" },
{ from: "change_description_requested", to: "production_ready", on: "system.advance" },
{ from: "change_description_requested", to: "won", on: "operator.won" },
{ from: "change_description_requested", to: "lost", on: "system.timeout" },
{ from: "operator_review", to: "won", on: "operator.won" },
{ from: "operator_review", to: "lost", on: "operator.lost" },
{ from: "operator_review", to: "production_ready", on: "system.advance" },
];
// ---------------------------------------------------------------------------
// Legacy denylist + Make.com exclusion (governance teeth)
// ---------------------------------------------------------------------------
/**
* Legacy UChat stage strings from the retired 10-flow export. These are reference
* only and MUST NOT appear as canonical stages or in funnel config/copy. The
* validators hard-fail on any of these (RFC-0188 nonGoals).
*/
export const LEGACY_FUNNEL_STAGES: readonly string[] = [
"q_website_tier",
"new_chat",
"q_new",
"q_what_important",
"q_company",
"q_industry",
"q_region",
"after_start_now_delay",
"start_now_or_later",
];
/**
* Pure scanner: report every Make.com reference in `text` (line, matched token).
* The funnel path — config, content, and code — must contain ZERO references, in
* any mode, transitional or permanent (RFC-0188 Decision §5). Integromat is the
* legacy name for Make.com and is denied too.
*/
export function scanForMakeComReferences(text: string): Array<{ line: number; match: string }> {
const pattern = /\b(?:hook\.(?:[a-z0-9-]+\.)*)?make\.com\b|\bintegromat\b/gi;
const hits: Array<{ line: number; match: string }> = [];
text.split(/\r?\n/).forEach((raw, index) => {
for (const m of raw.matchAll(pattern)) {
hits.push({ line: index + 1, match: m[0] });
}
});
return hits;
}
// ---------------------------------------------------------------------------
// Generic deal stage bridge (formerly in crm-buffer.ts — RFC-0188 Phase 3)
// ---------------------------------------------------------------------------
/**
* Closed catalog of deal funnel stages. Order matters — later stages come after
* earlier ones. Mirrors the standard Pipedrive pipeline stages.
*/
export const BUFFER_DEAL_STAGES = [
"new",
"contacted",
"qualified",
"proposal",
"negotiation",
"won",
"lost",
] as const;
export type BufferDealStage = (typeof BUFFER_DEAL_STAGES)[number];
/**
* RFC-0188 keeps the precise, platform-owned VisitorFunnelStage on the deal
* (`funnel_stage`) while bridging it down to the generic BufferDealStage the
* Pipedrive sync worker already understands. Exhaustive by construction.
*/
export const FUNNEL_STAGE_TO_BUFFER_STAGE: Record = {
new_session: "new",
privacy_acknowledged: "new",
intent_selected: "contacted",
organization_selected: "contacted",
qualification_priority: "qualified",
qualification_company: "qualified",
qualification_service: "qualified",
qualification_region: "qualified",
offer_presented: "proposal",
payment_pending: "proposal",
payment_confirmed: "negotiation",
start_choice_pending: "negotiation",
start_deferred: "negotiation",
buyer_type_pending: "negotiation",
b2b_start_consent_pending: "negotiation",
b2c_withdrawal_consent_pending: "negotiation",
start_approved: "negotiation",
legal_data_requested: "negotiation",
materials_requested: "negotiation",
production_ready: "negotiation",
change_balance_checked: "contacted",
change_payment_pending: "proposal",
change_description_requested: "negotiation",
operator_review: "negotiation",
won: "won",
lost: "lost",
};
/** Bridge a canonical funnel stage down to the generic stage the sync worker maps. */
export function bridgeFunnelStage(stage: VisitorFunnelStage): BufferDealStage {
return FUNNEL_STAGE_TO_BUFFER_STAGE[stage];
}
// ---------------------------------------------------------------------------
// Sync outbox statuses (formerly in crm-buffer.ts)
// ---------------------------------------------------------------------------
export const SYNC_OUTBOX_STATUSES = ["pending", "processing", "done", "failed", "dead"] as const;
export type SyncOutboxStatus = (typeof SYNC_OUTBOX_STATUSES)[number];
export const SYNC_OUTBOX_OPS = [
"upsert_contact",
"upsert_deal",
"update_deal_stage",
"upsert_organization",
"upsert_subscription",
"upsert_invoice",
] as const;
export type SyncOutboxOp = (typeof SYNC_OUTBOX_OPS)[number];