/* RFC-0176: Lagebild as a destination-hub adapter (kind `crm`, vendor `lagebild`). Wraps the Lagebild ingress client (submitIngress) in the DestinationAdapter contract so the delivery callback can route IntegrationEvents to Lagebild via the standard extraAdapters injection — with QStash retries, DLQ, and Redis idempotency preserved end-to-end. Do not import astro:env — secrets are injected by the caller. Do not log or echo secrets. Do not modify the WebsiteIngress v1 contract — this adapter only maps IntegrationEvent → IngressSubmitInput. 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. RFC-1198: cookie-free referral attribution — /r/ partner landings + voluntary referrer field Replace the RFC-1191 wg_ref cookie capture with URL+form attribution: static /r/ and //r/ partner landings (noindex, zero JS, consent-gated via publicNameConsentRef), ?ref prefill on the existing referrerField rail, server-side slug resolution against the bundled partners.yaml, referral_partner_slug/name on the Lagebild ingress contract, legacy /?ref -> /r/ 302 in the generated worker, and full removal of the cookie capture module (wg_ref, referral.attribution entitlement, payload.attribution). RFC-1222: pass through the server-resolved receipt provenance — partnerMode, policyEffectiveFrom, capturedAt, disclosureVersion. RFC-1239: Verbund Seam C (spec v0.1.1 V04, handoffs.yaml H02) — emit process_hint on real funnel transitions (payload.stage ≠ previousStage). Detection and mapping live here (the only layer that sees IntegrationEvent); serialization lives in lagebild-ingress.ts. Identity claims fall back to VisitorFunnelEventPayload.contact so funnel-only checkpoints still resolve the party via hub-side contact-value dedup; funnel-only submissions use interaction_kind "other" (the wire enum has no "chat"). RFC-1241: Verbund V05 — emit acquisition-context on website-ingress (first-touch UTM) RFC-1241: sessionStorage-captured first-touch utm_* flows browser -> send-message -> IntegrationEvent.payload.acquisition -> lagebild destination adapter -> website-ingress/v1 acquisition field. Producer validates the contract shape at every edge; malformed degrades to absent, never a dispatch error. contract_version stays 1. - shared: acquisition.ts (capture/read/isValid, storage-injected) - ingress: IngressSubmitInput.acquisition -> wire field - adapter: validated pass-through of payload.acquisition - site: SendMessageRequestSchema.acquisition + client attach + api re-validation + site-wide capture mount in layout-component - codegen repin v0.1.3 -> v0.1.4 (VID-016 republication); generated types unchanged in shape, new sourceHash - conformance: emitted acquisition + emitted ingress surface validated per snapshot (82 tests, 5 snapshots) RFC-1097 */ import type { DestinationAdapter, IntegrationEvent, IntegrationSecrets } from "./port.ts"; import { submitIngress, buildIdempotencyKey, type IdentityClaim, type IngressOrigin, type IngressProcessHint, type IngressInteractionKind, } from "./lagebild-ingress.ts"; import { isValidFunnelStage } from "./funnel.ts"; import { isValidAcquisitionContext } from "./acquisition.ts"; /** RFC-1239: narrows to the funnel-event marker pair (stage fields probed separately). */ function isFunnelPayload( payload: Record, ): payload is Record & { funnelVersion: string; eventKind: string } { return typeof payload.funnelVersion === "string" && typeof payload.eventKind === "string"; } /** * RFC-1239 (Verbund V04 / handoffs.yaml H02): map a funnel-carrying * `IntegrationEvent.payload` onto the process-hint/v1 object. Returns * `undefined` when the payload is not a funnel event or carries no real * transition (`stage` absent, invalid, or equal to `previousStage`) — the * submission then goes out without `process_hint`. * * The hint is non-authoritative: Lagebild stores `phase` as opaque * Opportunity.state under `catalog_ref` — the funnel catalog stays * platform-owned (RFC-0188). `system` is the literal "werkstatt"; the hub * rejects `personal_agent` on this path with 400. */ export function funnelPayloadToProcessHint( payload: Record, occurredAt: string, ): IngressProcessHint | undefined { if (!isFunnelPayload(payload)) return undefined; const stage = payload.stage; const previousStage = payload.previousStage; if (typeof stage !== "string" || !isValidFunnelStage(stage)) return undefined; if (typeof previousStage === "string" && previousStage === stage) return undefined; const previous_phase = typeof previousStage === "string" && isValidFunnelStage(previousStage) ? previousStage : undefined; return { schema: "process-hint/v1", system: "werkstatt", catalog_ref: "werkstatt:visitor-funnel/v1", phase: stage, ...(previous_phase !== undefined ? { previous_phase } : {}), changed_at: occurredAt, reason: payload.eventKind, }; } /** * Lagebild destination adapter (RFC-0176). Self-enables by secret presence: * skips silently when LAGEBILD_* secrets are absent. Throws on transport * failure or non-acceptance so the delivery callback returns 502 and QStash * retries (bounded → DLQ). */ export const lagebildDestinationAdapter: DestinationAdapter = { kind: "crm", vendor: "lagebild", requiredSecrets: [ "LAGEBILD_API_URL", "LAGEBILD_API_KEY", "LAGEBILD_TENANT_ID", "LAGEBILD_SOURCE_SYSTEM_ID", ], async route( event: IntegrationEvent, secrets: IntegrationSecrets, ): Promise<{ id: string } | null> { const apiUrl = secrets.LAGEBILD_API_URL; const apiKey = secrets.LAGEBILD_API_KEY; const tenantId = secrets.LAGEBILD_TENANT_ID; const sourceSystemId = secrets.LAGEBILD_SOURCE_SYSTEM_ID; if (!apiUrl || !apiKey || !tenantId || !sourceSystemId) { throw new Error("lagebild: missing credentials"); } const submissionId = "sub_" + event.eventId.replace(/-/g, "").slice(0, 24); // RFC-1239: funnel payloads carry contact inside `payload.contact` // (VisitorFunnelEventPayload) — funnel-only checkpoints must still resolve // the party via hub-side contact-value dedup. const payloadContact = typeof event.payload.contact === "object" && event.payload.contact !== null ? (event.payload.contact as { name?: string; email?: string; phone?: string }) : undefined; const contactEmail = event.contact?.email ?? payloadContact?.email; const contactPhone = event.contact?.phone ?? payloadContact?.phone; const claims: IdentityClaim[] = []; if (contactEmail) { claims.push({ claim_type: "email", value: contactEmail }); } if (contactPhone) { claims.push({ claim_type: "phone", value: contactPhone }); } const referrer = typeof event.payload.referrer === "string" ? event.payload.referrer : undefined; const origin: IngressOrigin = { locale: event.locale, ...(referrer ? { referrer_uri: referrer } : {}), }; const message = typeof event.payload.message === "string" ? event.payload.message : null; // RFC-1239: the hint is built here — this adapter is the only layer that // sees the IntegrationEvent. "other" (not "contact_message") when a funnel // checkpoint carries no message; the wire enum has no "chat" — the hub // derives interaction.kind: chat from the hint itself (H02). const processHint = funnelPayloadToProcessHint(event.payload, event.occurredAt); const interactionKind: IngressInteractionKind = message == null && isFunnelPayload(event.payload) ? "other" : "contact_message"; // RFC-1198: server-resolved partner fields pass through verbatim — the site // API already matched the raw referrer input against the bundled partner // list. A non-string/empty value is simply omitted, never a dispatch error. const referralPartnerSlug = typeof event.payload.referralPartnerSlug === "string" && event.payload.referralPartnerSlug ? event.payload.referralPartnerSlug : undefined; const referralPartnerName = typeof event.payload.referralPartnerName === "string" && event.payload.referralPartnerName ? event.payload.referralPartnerName : undefined; // RFC-1222: receipt provenance rides the same payload — server-resolved on // the site, passed through verbatim here, snake_cased at the ingress edge. const partnerMode = typeof event.payload.partnerMode === "string" && event.payload.partnerMode ? event.payload.partnerMode : undefined; const policyEffectiveFrom = typeof event.payload.policyEffectiveFrom === "string" && event.payload.policyEffectiveFrom ? event.payload.policyEffectiveFrom : undefined; const capturedAt = typeof event.payload.capturedAt === "string" && event.payload.capturedAt ? event.payload.capturedAt : undefined; const disclosureVersion = typeof event.payload.disclosureVersion === "string" && event.payload.disclosureVersion ? event.payload.disclosureVersion : undefined; // RFC-1241 (Verbund V05): first-touch UTM context rides the same payload — // captured browser-side, validated structurally here; a malformed block is // omitted, never a dispatch error (mirrors the hub's silent-strip rule). const acquisition = isValidAcquisitionContext(event.payload.acquisition) ? event.payload.acquisition : undefined; const result = await submitIngress( { apiUrl, apiKey, tenantId, sourceSystemId }, { submissionId, idempotencyKey: buildIdempotencyKey(tenantId, submissionId), interactionKind, identityClaims: claims, message, origin, explicitPolicyAssertions: [ { kind: "consent", purpose: "crm_projection", value: true, statement_ref: "contact_form_consent", }, ], ...(referralPartnerSlug ? { referralPartnerSlug } : {}), ...(referralPartnerName ? { referralPartnerName } : {}), ...(partnerMode ? { partnerMode } : {}), ...(policyEffectiveFrom ? { policyEffectiveFrom } : {}), ...(capturedAt ? { capturedAt } : {}), ...(disclosureVersion ? { disclosureVersion } : {}), ...(processHint ? { processHint } : {}), ...(acquisition ? { acquisition } : {}), }, ); if (!result.accepted) { throw new Error(`lagebild: ingress rejected (${result.rejection_class ?? "unknown"})`); } return result.submission_id ? { id: result.submission_id } : null; }, };