import * as z from "zod/mini"; import { appEventCustomerNameSchema } from "./app-event.js"; import { attentionPolicyConfigSchema, DEFAULT_ATTENTION_POLICY_CONFIG, legacyAttentionPolicyConfigSchema, type ResolvedAttentionPolicyConfig, } from "./attention-policy.js"; import { type DisplayAttentionPolicy, displayAttentionPolicySchema, } from "./display-attention-policy.js"; import { flowAssignmentIdentitySchema, flowAssignmentRecordSchema, flowAssignmentTargetingContextSchema, } from "./flow-assignments.js"; import { hostActionDefinitionSchema } from "./widget-config.js"; /** Core advertises this on flow-display sync before the server may expose a * runtime broadcast whose named budget requires assignment-backed admission. * Governing contract: docs/specs/2026-06-21-attention-budgeting-steelthread.md */ export const FLOW_DISPLAY_NAMED_BUDGET_CLAIM_CAPABILITY = "gx.flow-display.named-budget-claim.v1" as const; /** * Core advertises this before the server may expose customer-occurrence * runtime broadcasts in the flow-display manifest. */ export const FLOW_DISPLAY_CUSTOMER_OCCURRENCE_TRIGGER_CAPABILITY = "gx.flow-display.customer-occurrence-trigger.v1" as const; const legacyHostActionRequirementSchema = z.strictObject({ key: z.string().check(z.trim(), z.minLength(1)), contractVersion: z.string().check(z.trim(), z.minLength(1)), }); export const runtimeDisplayEligibilitySchema = z.object({ /** Presence proves the manifest producer evaluated the served flow for * required host actions. Consumers decide which positive versions they * understand and suppress absent or unsupported versions per opportunity. */ hostActionRequirementsVersion: z.optional( z.number().check(z.int(), z.positive()), ), requiredCapabilities: z._default( z.array(z.string().check(z.trim(), z.minLength(1))), [], ), requiredPage: z.optional( z.object({ path: z.string().check(z.trim(), z.minLength(1)), }), ), // Optional keeps older manifests and typed producers source-compatible. // A capable Core must admit these exact references before claiming. requiredHostActions: z.optional( z.array( z.union([hostActionDefinitionSchema, legacyHostActionRequirementSchema]), ), ), /** Successful settlement required by the authored host effect. Link transport * is bounded admission evidence; the exact URL remains in the served survey. */ requiredHostActionSettlement: z.optional( z.discriminatedUnion("kind", [ z.object({ kind: z.literal("remain") }), z.object({ kind: z.literal("close") }), z.object({ kind: z.literal("link"), transport: z.enum(["browser_http", "unsupported"]), }), ]), ), }); export const runtimeDisplayTriggerSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("immediate"), }), z.object({ kind: z.literal("exit_intent"), }), z.object({ event: appEventCustomerNameSchema, kind: z.literal("customer_occurrence"), type: z.enum(["track", "page", "screen"]), }), ]); const immediateRuntimeDisplayTrigger: z.output< typeof runtimeDisplayTriggerSchema > = { kind: "immediate", }; export const runtimeDisplayAttentionPolicySchema = displayAttentionPolicySchema; export const runtimeDisplayBroadcastOpportunitySchema = z.object({ opportunityId: z.string().check(z.trim(), z.minLength(1)), flowId: z.string().check(z.trim(), z.minLength(1)), flowVersionId: z.string().check(z.trim(), z.minLength(1)), source: z.object({ kind: z.literal("runtime_broadcast"), automationId: z.optional(z.string().check(z.trim(), z.minLength(1))), automationVersionId: z.optional(z.string().check(z.trim(), z.minLength(1))), }), runtimeEligibility: runtimeDisplayEligibilitySchema, runtimeTrigger: z._default( runtimeDisplayTriggerSchema, immediateRuntimeDisplayTrigger, ), attentionPolicy: z.optional(runtimeDisplayAttentionPolicySchema), /** True when this opportunity must not display without a successful claim. * Set for journey-bound automations and broadcasts with required host * actions: an unclaimed display would lose required delivery or presentation * behavior, so claim failures skip instead of falling back to an unclaimed * open. Absent means the pre-claim best-effort fallback applies. */ claimRequired: z.optional(z.boolean()), }); export const runtimeDisplayBroadcastsSchema = z.object({ version: z.string().check(z.trim(), z.minLength(1)), opportunities: z.array(runtimeDisplayBroadcastOpportunitySchema), }); export const flowDisplaySyncRequestSchema = z.object({ identities: z._default(z.array(flowAssignmentIdentitySchema), []), context: z.optional(flowAssignmentTargetingContextSchema), holdOwnerKey: z.string().check(z.trim(), z.minLength(1)), protocolCapabilities: z.optional( z.array(z.string().check(z.trim(), z.minLength(1))), ), at: z.optional(z.number()), assignmentLimit: z.optional(z.number()), knownBroadcastManifestVersion: z.optional( z.string().check(z.trim(), z.minLength(1)), ), }); export const flowDisplaySyncResponseSchema = z.object({ fetchedAt: z.string(), attentionPolicy: z.optional(legacyAttentionPolicyConfigSchema), attentionPolicyV2: z.optional(attentionPolicyConfigSchema), assignments: z.object({ claimed: z.array(flowAssignmentRecordSchema), }), broadcasts: runtimeDisplayBroadcastsSchema, }); // Claim-on-display: called just before the widget opens a runtime-broadcast // view. The server materializes (or returns) a durable assignment for the // exposure, held under the caller's holdOwnerKey — the same key the widget // sends to `sync` — so the returned holdGroupToken drives the existing // mark-presented / mark-completed endpoints. export const flowDisplayClaimBroadcastRequestSchema = z.object({ automationId: z.string().check(z.trim(), z.minLength(1)), automationVersionId: z.string().check(z.trim(), z.minLength(1)), expectedFlowVersionId: z.optional(z.string().check(z.trim(), z.minLength(1))), identities: z._default(z.array(flowAssignmentIdentitySchema), []), context: z.optional(flowAssignmentTargetingContextSchema), holdOwnerKey: z.string().check(z.trim(), z.minLength(1)), protocolCapabilities: z.optional( z.array(z.string().check(z.trim(), z.minLength(1))), ), }); export const flowDisplayClaimedBroadcastAssignmentSchema = z.object({ flowAssignmentId: z.string().check(z.trim(), z.minLength(1)), flowVersionId: z.string().check(z.trim(), z.minLength(1)), status: z.enum([ "pending", "held", "presented", "completed", "expired", "superseded", ]), /** Null when the claim comes back without a usable hold: the row is already * `presented`, or another widget instance actively holds it. The widget must * not display in that case. */ holdGroupToken: z.union([ z.string().check(z.trim(), z.minLength(1)), z.null(), ]), }); export const flowDisplayClaimBroadcastResponseSchema = z.discriminatedUnion( "outcome", [ z.object({ outcome: z.literal("claimed"), assignment: flowDisplayClaimedBroadcastAssignmentSchema, }), z.object({ outcome: z.literal("existing"), assignment: flowDisplayClaimedBroadcastAssignmentSchema, }), z.object({ outcome: z.literal("ineligible"), // Additive compatibility encoding: older widgets already interpret // `ineligible` as a retryable skip, while current widgets can classify // durable attention suppression without risking an unclaimed display. reason: z.optional(z.literal("recent_flow_cooldown")), }), ], ); export type RuntimeDisplayBroadcasts = z.output< typeof runtimeDisplayBroadcastsSchema >; export type RuntimeDisplayBroadcastOpportunity = z.output< typeof runtimeDisplayBroadcastOpportunitySchema >; export type RuntimeDisplayAttentionPolicy = DisplayAttentionPolicy; export type RuntimeDisplayEligibility = z.output< typeof runtimeDisplayEligibilitySchema >; export type RuntimeDisplayTrigger = z.output< typeof runtimeDisplayTriggerSchema >; export type FlowDisplaySyncRequest = z.output< typeof flowDisplaySyncRequestSchema >; export type FlowDisplaySyncResponse = z.output< typeof flowDisplaySyncResponseSchema >; export type FlowDisplayClaimBroadcastRequest = z.output< typeof flowDisplayClaimBroadcastRequestSchema >; export type FlowDisplayClaimBroadcastResponse = z.output< typeof flowDisplayClaimBroadcastResponseSchema >; export type FlowDisplayClaimedBroadcastAssignment = z.output< typeof flowDisplayClaimedBroadcastAssignmentSchema >; export type FlowDisplayAttentionPolicyConfig = ResolvedAttentionPolicyConfig; export const resolveFlowDisplayAttentionPolicyConfig = ( displaySync: Pick< FlowDisplaySyncResponse, "attentionPolicy" | "attentionPolicyV2" >, ): FlowDisplayAttentionPolicyConfig | undefined => { const policy = displaySync.attentionPolicyV2 ?? displaySync.attentionPolicy; if (!policy) { return undefined; } return { version: policy.version, source: policy.source, recentFlowCooldownMs: policy.recentFlowCooldownMs, recentInAppCooldownMs: "recentInAppCooldownMs" in policy && typeof policy.recentInAppCooldownMs === "number" ? policy.recentInAppCooldownMs : DEFAULT_ATTENTION_POLICY_CONFIG.recentInAppCooldownMs, automaticInAppWeeklyLimit: "automaticInAppWeeklyLimit" in policy && typeof policy.automaticInAppWeeklyLimit === "number" ? policy.automaticInAppWeeklyLimit : DEFAULT_ATTENTION_POLICY_CONFIG.automaticInAppWeeklyLimit, exclusiveSurfaceStrategy: policy.exclusiveSurfaceStrategy, blockedBroadcastStrategy: policy.blockedBroadcastStrategy, }; };