@revturbine/cli 0.19.3 → 0.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1578 -42
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2353,6 +2353,8 @@ var CtaActionTypeSchema = z2.enum([
2353
2353
  "snooze",
2354
2354
  "custom"
2355
2355
  ]).meta({ id: "CtaActionType", "x-revturbine-schema-persistence": Transient, "x-revturbine-schema-exposure": External });
2356
+ var ObjectiveField = HandleField.optional();
2357
+ var NullableObjectiveField = z2.string().min(1).max(100).nullable().optional();
2356
2358
  var SchemaContext = {
2357
2359
  Playbook: "playbook",
2358
2360
  Branding: "branding",
@@ -2998,6 +3000,16 @@ var EntitlementRuleSchema = IdField.merge(TimestampFields).merge(TenantIdField).
2998
3000
  // "match all users" (replaces the legacy 'all' sentinel).
2999
3001
  segment_ids: z6.array(z6.string()).default([]).meta(Unrestricted3),
3000
3002
  visibility: RuleVisibilitySchema.default("public").meta(Unrestricted3),
3003
+ // Business objective this rule monetizes for — the analytics `objective`
3004
+ // slice, which resolves `rule_handle → objective` through a configuration
3005
+ // snapshot (BL-0065 / worksheet G4). A reference by handle to an
3006
+ // `objectives[]` entity (D-15; VAL-OBJ-01). Config-only, and deliberately
3007
+ // NOT part of the minted identity below: an objective relabel is a
3008
+ // behaviour-only edit that coalesces onto the same rule, not a new rule scope.
3009
+ // Nullable on the persisted entity so an editor can CLEAR the reference
3010
+ // (a PATCH merges, so omission keeps the old value); the portable
3011
+ // projection stays optional-only and omits it when unset.
3012
+ objective: NullableObjectiveField.meta(Unrestricted3),
3001
3013
  // Usage-Limit "measured over" window, rule-level (plan #55). Rate Limit
3002
3014
  // keeps its entitlement-level `period_scope`; this is the per-rule one.
3003
3015
  period_scope: UsagePeriodScopeSchema.optional().meta(Unrestricted3),
@@ -3351,6 +3363,17 @@ var PlacementSchema = IdField.merge(TimestampFields).merge(TenantIdField).merge(
3351
3363
  handle: HandleField.meta(Unrestricted4),
3352
3364
  description: DescriptionField.meta(Unrestricted4),
3353
3365
  category: PlacementCategorySchema.meta(Unrestricted4),
3366
+ // Business objective this placement monetizes for — the analytics
3367
+ // `objective` slice, which resolves `placement_id → objective` through a
3368
+ // configuration snapshot (BL-0065 / worksheet G4). A reference by handle to
3369
+ // an `objectives[]` entity (D-15; VAL-OBJ-01). Config-only: nothing in the
3370
+ // runtime decision reads it, so it round-trips through the portable
3371
+ // Playbook projection but never lowers into the compiled IR. See
3372
+ // `ObjectiveField`.
3373
+ // Nullable on the persisted entity so an editor can CLEAR the reference
3374
+ // (a PATCH merges, so omission keeps the old value); the portable
3375
+ // projection stays optional-only and omits it when unset.
3376
+ objective: NullableObjectiveField.meta(Unrestricted4),
3354
3377
  drag_order_in_category: z7.number().int().default(0).meta(Unrestricted4),
3355
3378
  // Trigger config (populated based on category)
3356
3379
  surface_slot_id: z7.string().optional().meta(Unrestricted4),
@@ -3830,6 +3853,34 @@ var UserPlanContextSchema = z9.object({
3830
3853
  }).meta(
3831
3854
  { id: "UserPlanContext", "x-revturbine-schema-persistence": Transient6, "x-revturbine-schema-exposure": External5 }
3832
3855
  );
3856
+ var BuiltinActivityLevelSchema = z9.enum(["new", "high", "medium", "low", "inactive"]);
3857
+ var BuiltinSubscriptionStateSchema = z9.enum(["none", "trial", "paid", "cancelled"]);
3858
+ var BuiltinTrialTypeSchema = z9.enum(["none", "free_trial", "reverse_trial"]);
3859
+ var BuiltinBuyerRoleSchema = z9.enum(["buyer", "non_buyer"]);
3860
+ var BuiltinEmailTypeSchema = z9.enum(["business", "personal", "unknown"]);
3861
+ var BuiltinBillingHealthSchema = z9.enum([
3862
+ "no_billing",
3863
+ "good_standing",
3864
+ "trial_payment_method_attached",
3865
+ "payment_method_missing",
3866
+ "payment_failed",
3867
+ "payment_overdue",
3868
+ "cancelled"
3869
+ ]);
3870
+ var BuiltinRegionSchema = z9.enum(["us_canada", "europe", "rest_of_world"]);
3871
+ var BuiltinDeviceTypeSchema = z9.enum(["desktop", "mobile", "tablet", "unknown"]);
3872
+ var BuiltinSeatTypeSchema = z9.string().regex(/^[a-z0-9._]{1,87}$/);
3873
+ var UserBuiltinDimensionsSchema = z9.object({
3874
+ activity_level: BuiltinActivityLevelSchema.optional().meta(Unrestricted6),
3875
+ subscription_state: BuiltinSubscriptionStateSchema.optional().meta(Unrestricted6),
3876
+ trial_type: BuiltinTrialTypeSchema.optional().meta(Unrestricted6),
3877
+ seat_type: BuiltinSeatTypeSchema.optional().meta(Unrestricted6),
3878
+ buyer_role: BuiltinBuyerRoleSchema.optional().meta(Unrestricted6),
3879
+ email_type: BuiltinEmailTypeSchema.optional().meta(Unrestricted6),
3880
+ billing_health: BuiltinBillingHealthSchema.optional().meta(Unrestricted6),
3881
+ region: BuiltinRegionSchema.optional().meta(Unrestricted6),
3882
+ device_type: BuiltinDeviceTypeSchema.optional().meta(Unrestricted6)
3883
+ });
3833
3884
  var UserInstanceContextSchema = z9.object({
3834
3885
  product_instance_id: z9.string().min(1).meta(Unrestricted6),
3835
3886
  user_id: z9.string().min(1).meta(Pii2),
@@ -3892,13 +3943,27 @@ var UserContextSchema = IdField.merge(TenantIdField).merge(TimestampFields).exte
3892
3943
  /** Customer-defined fields: role, app-level permissions, display prefs. */
3893
3944
  custom: z9.record(z9.string(), z9.union([z9.string(), z9.number(), z9.boolean(), z9.null()])).default({}).meta(Pii2),
3894
3945
  /**
3895
- * Transient personalization token map.
3946
+ * Personalization token map.
3896
3947
  *
3897
3948
  * Holds SDK-derived tokens (plan_name, usage_current, etc.) merged with
3898
- * app-provided tokens. Not persisted to the backend — rebuilt on each
3899
- * SDK session from context + exported config.
3949
+ * app-provided tokens; the SDK rebuilds it on each session from context +
3950
+ * exported config.
3951
+ *
3952
+ * Deliberately NOT marked field-level Transient: the `user_contexts` table
3953
+ * carries a `personalization` jsonb column (web `drizzle/0000_*.sql`) and
3954
+ * the generic user-context write persists it (web
3955
+ * `src/lib/user-context/sanitize-write.ts` redacts it first). The drizzle
3956
+ * generator emits no column for a field-level Transient (plan 279
3957
+ * TASK-3), so the label has to match the table.
3900
3958
  */
3901
- personalization: z9.record(z9.string(), z9.union([z9.string(), z9.number()])).default({}).meta({ ...Unrestricted6, "x-revturbine-schema-persistence": Transient6 }),
3959
+ personalization: z9.record(z9.string(), z9.union([z9.string(), z9.number()])).default({}).meta(Unrestricted6),
3960
+ /**
3961
+ * Built-in segment dimension values (plan 279 PD-3) — see
3962
+ * {@link UserBuiltinDimensionsSchema}. Field-level Transient: no DB
3963
+ * column; assembled at retrieval, or set by the app in `local_only` mode
3964
+ * and on the server ports.
3965
+ */
3966
+ builtin_dimensions: UserBuiltinDimensionsSchema.optional().meta({ ...Unrestricted6, "x-revturbine-schema-persistence": Transient6 }),
3902
3967
  // ── Derived-entitlement cache (plan 74 REQ-12/REQ-13) ──────────────
3903
3968
  // `entitlements` above is the rule-evaluated projection — a CACHE, not
3904
3969
  // source of truth. These stamps record what it was computed against so a
@@ -3984,6 +4049,18 @@ var ClientContextPlanSchema = z9.object({
3984
4049
  }).meta(
3985
4050
  { id: "ClientContextPlan", "x-revturbine-schema-persistence": Transient6, "x-revturbine-schema-exposure": Internal5 }
3986
4051
  );
4052
+ var ClientContextBuiltinDimensionsSchema = z9.object({
4053
+ activity_level: BuiltinActivityLevelSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4054
+ subscription_state: BuiltinSubscriptionStateSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4055
+ trial_type: BuiltinTrialTypeSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4056
+ seat_type: BuiltinSeatTypeSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4057
+ buyer_role: BuiltinBuyerRoleSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4058
+ email_type: BuiltinEmailTypeSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4059
+ region: BuiltinRegionSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4060
+ device_type: BuiltinDeviceTypeSchema.optional().meta({ ...Unrestricted6, ...ClientSafe })
4061
+ }).meta(
4062
+ { id: "ClientContextBuiltinDimensions", "x-revturbine-schema-persistence": Transient6, "x-revturbine-schema-exposure": Internal5 }
4063
+ );
3987
4064
  var ClientContextSchema = z9.object({
3988
4065
  /** The end-user subject the client token was minted for (carried by the token, not the request). */
3989
4066
  subject: z9.string().min(1).meta({ ...Unrestricted6, ...ClientSafe }),
@@ -3992,7 +4069,9 @@ var ClientContextSchema = z9.object({
3992
4069
  trial: ClientContextTrialSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
3993
4070
  billing: ClientContextBillingSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
3994
4071
  plan: ClientContextPlanSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
3995
- capabilities: ClientContextCapabilitiesSchema.optional().meta({ ...Unrestricted6, ...ClientSafe })
4072
+ capabilities: ClientContextCapabilitiesSchema.optional().meta({ ...Unrestricted6, ...ClientSafe }),
4073
+ /** Server-evaluated built-in segment dimensions (plan 279 PD-3). */
4074
+ builtin_dimensions: ClientContextBuiltinDimensionsSchema.optional().meta({ ...Unrestricted6, ...ClientSafe })
3996
4075
  }).meta(
3997
4076
  { id: "ClientContext", "x-revturbine-schema-persistence": Transient6, "x-revturbine-schema-exposure": Internal5 }
3998
4077
  );
@@ -4864,6 +4943,91 @@ var AnalyticsMetricLayerSchema = z14.enum(["primitive", "derived"]).meta(meta("A
4864
4943
  var AnalyticsCatalogStatusSchema = z14.enum(["declared", "bound", "validated", "tested", "unavailable"]).meta(meta("AnalyticsCatalogStatus"));
4865
4944
  var AnalyticsFactKindSchema = z14.enum(["transaction", "periodic_snapshot", "accumulating_snapshot"]).meta(meta("AnalyticsFactKind"));
4866
4945
  var AnalyticsConceptProducerSchema = z14.enum(["platform", "customer_authored", "simulation"]).meta(meta("AnalyticsConceptProducer"));
4946
+ var AnalyticsRecordDedupSchema = z14.enum(["deduplicated_on_record_id", "summed_on_sorting_key", "append_only", "upsert_on_primary_key", "recomputed_at_query", "unknown"]).meta(meta("AnalyticsRecordDedup"));
4947
+ var AnalyticsRecordRevisionSchema = z14.enum(["immutable", "re_emit_higher_version", "in_place_update", "recomputed_from_source", "unknown"]).meta(meta("AnalyticsRecordRevision"));
4948
+ var AnalyticsRecordLateArrivalSchema = z14.enum(["accepted", "accepted_within_retention", "rejected", "unknown"]).meta(meta("AnalyticsRecordLateArrival"));
4949
+ var AnalyticsRecordContractSchema = z14.object({
4950
+ /** The columns whose tuple is the stable record id. Identity resolution is never part of it (R2). */
4951
+ record_id: z14.array(z14.string().min(1).max(120)).min(1).max(8).meta(Unrestricted11),
4952
+ /** Where the rows come from: `stripe`, `sdk_clickstream`, `revturbine_control_plane`. */
4953
+ source_namespace: z14.string().min(1).max(120).meta(Unrestricted11),
4954
+ /** Occurred (effective) time. */
4955
+ occurred_time_field: z14.string().min(1).max(120).meta(Unrestricted11),
4956
+ /** Received (observation) time; absent when the source records none. */
4957
+ received_time_field: z14.string().min(1).max(120).optional().meta(Unrestricted11),
4958
+ dedup: AnalyticsRecordDedupSchema.meta(Unrestricted11),
4959
+ revision: AnalyticsRecordRevisionSchema.meta(Unrestricted11),
4960
+ late_arrival: AnalyticsRecordLateArrivalSchema.meta(Unrestricted11),
4961
+ correlation_keys: z14.array(z14.string().min(1).max(120)).max(12).optional().meta(Unrestricted11),
4962
+ /**
4963
+ * The field carrying Stripe live/test mode. R2 requires it on every
4964
+ * billing fact; a billing concept that declares `livemode_qualified: true`
4965
+ * must name it, and one that cannot is `livemode_qualified: false` with
4966
+ * the gap stated in `note`.
4967
+ */
4968
+ livemode_field: z14.string().min(1).max(120).optional().meta(Unrestricted11),
4969
+ /** Stamped policy-version fields (R2/R9). Empty or absent = none stamped. */
4970
+ policy_version_fields: z14.array(z14.string().min(1).max(120)).max(8).optional().meta(Unrestricted11),
4971
+ note: z14.string().max(400).optional().meta(Unrestricted11)
4972
+ }).meta(meta("AnalyticsRecordContract"));
4973
+ var AnalyticsKeyResolutionSchema = z14.enum(["source", "enriched", "absent"]).meta(meta("AnalyticsKeyResolution"));
4974
+ var AnalyticsConceptKeySchema = z14.object({
4975
+ key: z14.string().regex(/^[a-z][a-z0-9_]{0,49}$/).meta(Unrestricted11),
4976
+ /** `column:<name>`, `payload:<field>`, `envelope:<field>` - or `-` when absent. */
4977
+ path: z14.string().min(1).max(200).meta(Unrestricted11),
4978
+ resolution: AnalyticsKeyResolutionSchema.meta(Unrestricted11),
4979
+ note: z14.string().max(400).optional().meta(Unrestricted11)
4980
+ }).meta(meta("AnalyticsConceptKey"));
4981
+ var AnalyticsMeasureUnitSchema = z14.enum(["currency_minor", "count", "seconds", "ratio", "timestamp", "usage_unit", "polymorphic"]).meta(meta("AnalyticsMeasureUnit"));
4982
+ var AnalyticsMeasureTemporalSchema = z14.enum(["stock", "flow", "gauge"]).meta(meta("AnalyticsMeasureTemporal"));
4983
+ var AnalyticsMeasureSignSchema = z14.enum(["signed", "positive_magnitude", "unsigned"]).meta(meta("AnalyticsMeasureSign"));
4984
+ var AnalyticsConceptMeasureSchema = z14.object({
4985
+ name: z14.string().regex(/^[a-z][a-z0-9_]{0,63}$/).meta(Unrestricted11),
4986
+ /** `column:<name>` or `row_count` - a transaction fact whose measure IS its row count. */
4987
+ source: z14.string().min(1).max(200).meta(Unrestricted11),
4988
+ unit: AnalyticsMeasureUnitSchema.meta(Unrestricted11),
4989
+ temporal: AnalyticsMeasureTemporalSchema.meta(Unrestricted11),
4990
+ sign: AnalyticsMeasureSignSchema.meta(Unrestricted11),
4991
+ aggregation_semantics: AnalyticsMetricAggregationSemanticsSchema.meta(Unrestricted11),
4992
+ note: z14.string().max(400).optional().meta(Unrestricted11)
4993
+ }).meta(meta("AnalyticsConceptMeasure"));
4994
+ var AnalyticsConceptEventAttributeSchema = z14.object({
4995
+ name: z14.string().regex(/^[a-z][a-z0-9_]{0,63}$/).meta(Unrestricted11),
4996
+ /** `column:<name>`, `payload:<field>` or `envelope:<field>`. */
4997
+ path: z14.string().min(1).max(200).meta(Unrestricted11),
4998
+ note: z14.string().max(400).optional().meta(Unrestricted11)
4999
+ }).meta(meta("AnalyticsConceptEventAttribute"));
5000
+ var AnalyticsConceptSourceKindSchema = z14.enum(["ingested_event", "datasource", "control_plane_table", "build", "unavailable"]).meta(meta("AnalyticsConceptSourceKind"));
5001
+ var AnalyticsConceptSourceSchema = z14.object({
5002
+ kind: AnalyticsConceptSourceKindSchema.meta(Unrestricted11),
5003
+ ref: z14.string().min(1).max(200).meta(Unrestricted11),
5004
+ /** Required when `kind` is `unavailable`: what has to ship before rows exist. */
5005
+ blocker: z14.string().max(400).optional().meta(Unrestricted11),
5006
+ note: z14.string().max(400).optional().meta(Unrestricted11)
5007
+ }).meta(meta("AnalyticsConceptSource"));
5008
+ var AnalyticsConceptBuildSchema = z14.object({
5009
+ kind: z14.enum(["pipe", "materialized_view", "worker", "query_time", "unavailable"]).meta(Unrestricted11),
5010
+ ref: z14.string().min(1).max(200).meta(Unrestricted11),
5011
+ inputs: z14.array(z14.string().min(1).max(200)).min(1).max(12).meta(Unrestricted11),
5012
+ blocker: z14.string().max(400).optional().meta(Unrestricted11)
5013
+ }).meta(meta("AnalyticsConceptBuild"));
5014
+ var AnalyticsConceptMaturitySchema = z14.object({
5015
+ status: z14.enum(["declared", "unavailable"]).meta(Unrestricted11),
5016
+ horizon_field: z14.string().min(1).max(120).optional().meta(Unrestricted11),
5017
+ observed_through_field: z14.string().min(1).max(120).optional().meta(Unrestricted11),
5018
+ blocker: z14.string().max(400).optional().meta(Unrestricted11)
5019
+ }).meta(meta("AnalyticsConceptMaturity"));
5020
+ var AnalyticsOracleFamilySchema = z14.enum(["gate", "presentation", "revenue", "attribution", "event_count", "cohort", "funnel", "read_through", "none"]).meta(meta("AnalyticsOracleFamily"));
5021
+ var AnalyticsConceptOracleFamilySchema = z14.object({
5022
+ family: AnalyticsOracleFamilySchema.meta(Unrestricted11),
5023
+ blocker: z14.string().max(400).optional().meta(Unrestricted11)
5024
+ }).meta(meta("AnalyticsConceptOracleFamily"));
5025
+ var AnalyticsPolicyKindSchema = z14.enum(["mrr", "attribution", "lifecycle", "engagement"]).meta(meta("AnalyticsPolicyKind"));
5026
+ var AnalyticsConceptPolicyDependencySchema = z14.object({
5027
+ kind: AnalyticsPolicyKindSchema.meta(Unrestricted11),
5028
+ stamped: z14.boolean().meta(Unrestricted11),
5029
+ blocker: z14.string().max(400).optional().meta(Unrestricted11)
5030
+ }).meta(meta("AnalyticsConceptPolicyDependency"));
4867
5031
  var AnalyticsGroundingAnchorSchema = z14.enum([
4868
5032
  "fact_time",
4869
5033
  "period_opening",
@@ -5064,13 +5228,20 @@ var AnalyticsCatalogConceptSchema = z14.object({
5064
5228
  * exposure, not per placement-day — a daily rollup is a pipe's physical
5065
5229
  * choice, not a second grain).
5066
5230
  *
5067
- * These fields are optional while the concepts migrate onto them. They
5068
- * are NOT decorative: a concept that declares `fact_kind` is asserting it
5069
- * has been mapped, and the structural rules tighten around it from there.
5070
- * Populating them for the existing concepts, and splitting `revenue.movement`
5071
- * into `revenue.stock` / `.movement` / `.ledger`, rides with the fact-table
5072
- * builds that produce the rows (later plan 252 tasks and plan 230) rather
5073
- * than being invented here.
5231
+ * These fields stay optional on the canonical schema so a partial fixture
5232
+ * still parses, but they are NOT decorative: a concept that declares
5233
+ * `fact_kind` is asserting it has been mapped, and
5234
+ * `AnalyticsCatalogConceptValidatedSchema` then REQUIRES the whole set —
5235
+ * the same split as the metric schema. Every shipped concept declares them
5236
+ * (BL-0246, TASK-58's second half).
5237
+ *
5238
+ * `revenue.movement` is still NOT split into `revenue.stock` /
5239
+ * `.movement` / `.ledger`: today's definitions do not distinguish them
5240
+ * (the only served measure is the invoice-paid cash proxy), so the split
5241
+ * stays registered as the `catalog_status: 'unavailable'` blocker on
5242
+ * `revenue.mrr` / `revenue.net_new_mrr` and rides with the normalized
5243
+ * recurring stock and movement oracle (plan 252 TASK-29/34/36/45) that
5244
+ * produce the rows. Declaring three empty concepts here would invent them.
5074
5245
  */
5075
5246
  fact_kind: AnalyticsFactKindSchema.optional().meta(Unrestricted11),
5076
5247
  family: z14.enum(["billing", "behavioral"]).optional().meta(Unrestricted11),
@@ -5084,8 +5255,286 @@ var AnalyticsCatalogConceptSchema = z14.object({
5084
5255
  }).optional().meta(Unrestricted11),
5085
5256
  /** Billing facts carry Stripe live/test mode; without it live and test revenue are indistinguishable. */
5086
5257
  livemode_qualified: z14.boolean().optional().meta(Unrestricted11),
5258
+ /** The record contract the rows obey (R2). */
5259
+ record_contract: AnalyticsRecordContractSchema.optional().meta(Unrestricted11),
5260
+ /** Every entity key the fact carries, and the path it resolves through (R1). */
5261
+ keys: z14.array(AnalyticsConceptKeySchema).max(24).optional().meta(Unrestricted11),
5262
+ /**
5263
+ * The stored measures. May be EMPTY for a concept every one of whose
5264
+ * metrics is `catalog_status: 'unavailable'` — there is no measure to
5265
+ * declare until the fact exists. The in-memory integrity check enforces
5266
+ * that pairing, so an empty list cannot hide a served metric.
5267
+ */
5268
+ measures: z14.array(AnalyticsConceptMeasureSchema).max(32).optional().meta(Unrestricted11),
5269
+ /** Non-key attributes carried on the row (R7). */
5270
+ event_attributes: z14.array(AnalyticsConceptEventAttributeSchema).max(32).optional().meta(Unrestricted11),
5271
+ /** Where the rows come from (R1/R5). */
5272
+ sources: z14.array(AnalyticsConceptSourceSchema).min(1).max(12).optional().meta(Unrestricted11),
5273
+ /** How snapshot rows are built (R5); required for both snapshot kinds. */
5274
+ build: AnalyticsConceptBuildSchema.optional().meta(Unrestricted11),
5275
+ /** Horizon / `observed_through` right-censoring (R5); required for accumulating snapshots. */
5276
+ maturity: AnalyticsConceptMaturitySchema.optional().meta(Unrestricted11),
5277
+ /** The pure oracle family that derives this concept's metrics (R6). */
5278
+ oracle_family: AnalyticsConceptOracleFamilySchema.optional().meta(Unrestricted11),
5279
+ /** The versioned policy kinds the rows depend on, and whether the version is stamped (R9). */
5280
+ policy_dependencies: z14.array(AnalyticsConceptPolicyDependencySchema).max(8).optional().meta(Unrestricted11),
5087
5281
  deprecation: CatalogDeprecation.optional().meta(Unrestricted11)
5088
5282
  }).meta(meta("AnalyticsCatalogConcept"));
5283
+ var AnalyticsCatalogConceptValidatedSchema = AnalyticsCatalogConceptSchema.superRefine(
5284
+ (concept, ctx) => {
5285
+ const problem = (path3, code, message) => {
5286
+ ctx.addIssue({ code: "custom", path: path3, params: { code }, message });
5287
+ };
5288
+ const duplicates = (names) => {
5289
+ const seen = /* @__PURE__ */ new Set();
5290
+ const dupes = /* @__PURE__ */ new Set();
5291
+ for (const name of names) {
5292
+ if (seen.has(name)) dupes.add(name);
5293
+ seen.add(name);
5294
+ }
5295
+ return [...dupes].sort();
5296
+ };
5297
+ if (concept.fact_kind === void 0) {
5298
+ for (const field of [
5299
+ "family",
5300
+ "primary_key",
5301
+ "producer",
5302
+ "materialization",
5303
+ "livemode_qualified",
5304
+ "record_contract",
5305
+ "keys",
5306
+ "measures",
5307
+ "event_attributes",
5308
+ "build",
5309
+ "maturity"
5310
+ ]) {
5311
+ if (concept[field] !== void 0) {
5312
+ problem(
5313
+ [field],
5314
+ "fact_table_field_without_fact_kind",
5315
+ `${field} describes a fact table's rows, so the concept must declare fact_kind`
5316
+ );
5317
+ }
5318
+ }
5319
+ for (const [index, source] of (concept.sources ?? []).entries()) {
5320
+ if (source.kind !== "unavailable") {
5321
+ problem(
5322
+ ["sources", index, "kind"],
5323
+ "real_source_without_fact_kind",
5324
+ "a concept naming a real row source must declare fact_kind (D-9: the concept IS the fact table)"
5325
+ );
5326
+ }
5327
+ }
5328
+ } else {
5329
+ const required = [
5330
+ "family",
5331
+ "primary_key",
5332
+ "producer",
5333
+ "materialization",
5334
+ "livemode_qualified",
5335
+ "record_contract",
5336
+ "keys",
5337
+ "measures",
5338
+ "event_attributes",
5339
+ "sources",
5340
+ "oracle_family",
5341
+ "policy_dependencies"
5342
+ ];
5343
+ for (const field of required) {
5344
+ if (concept[field] === void 0) {
5345
+ problem(
5346
+ [field],
5347
+ "mapped_concept_missing_fact_table_field",
5348
+ `a concept declaring fact_kind must declare ${field} (D-9: the concept IS the fact table)`
5349
+ );
5350
+ }
5351
+ }
5352
+ if (concept.keys !== void 0 && concept.keys.length === 0) {
5353
+ problem(["keys"], "mapped_concept_without_keys", "a fact carries at least one entity key (R1)");
5354
+ }
5355
+ for (const [dimensionId, grounding] of Object.entries(concept.dimension_groundings ?? {})) {
5356
+ if (grounding.anchor === void 0) {
5357
+ problem(
5358
+ ["dimension_groundings", dimensionId, "anchor"],
5359
+ "grounding_without_anchor",
5360
+ `grounding for ${dimensionId} must declare an anchor once the concept declares fact_kind (R8)`
5361
+ );
5362
+ }
5363
+ }
5364
+ if (concept.fact_kind === "accumulating_snapshot" && concept.maturity === void 0) {
5365
+ problem(
5366
+ ["maturity"],
5367
+ "accumulating_snapshot_without_maturity",
5368
+ "an accumulating snapshot must declare maturity \u2014 status 'unavailable' with a blocker when right-censoring is not implemented (R5)"
5369
+ );
5370
+ }
5371
+ if (concept.fact_kind !== "transaction" && concept.build === void 0) {
5372
+ problem(["build"], "snapshot_without_build", "a snapshot fact must declare the build that produces its rows (R5)");
5373
+ }
5374
+ if (concept.fact_kind === "transaction" && concept.maturity !== void 0) {
5375
+ problem(["maturity"], "transaction_with_maturity", "maturity is a snapshot property; a transaction fact has no horizon (R5)");
5376
+ }
5377
+ }
5378
+ for (const [dimensionId, grounding] of Object.entries(concept.dimension_groundings ?? {})) {
5379
+ if (grounding.catalog_status === "unavailable" && !grounding.blocker?.trim()) {
5380
+ problem(
5381
+ ["dimension_groundings", dimensionId, "blocker"],
5382
+ "unavailable_grounding_without_blocker",
5383
+ `grounding for ${dimensionId} is catalog_status 'unavailable' and must name the blocker (R11/G13)`
5384
+ );
5385
+ }
5386
+ if (grounding.catalog_status !== "unavailable" && grounding.blocker !== void 0) {
5387
+ problem(
5388
+ ["dimension_groundings", dimensionId, "blocker"],
5389
+ "grounding_blocker_without_unavailable",
5390
+ `grounding for ${dimensionId} carries a blocker, so its catalog_status must be 'unavailable' (R11/G13)`
5391
+ );
5392
+ }
5393
+ if (grounding.kind !== "config_join" && grounding.kind !== "membership_join") continue;
5394
+ if (grounding.catalog_status === "unavailable") continue;
5395
+ const joined = (concept.sources ?? []).some(
5396
+ (source) => source.ref === grounding.source && source.kind !== "unavailable"
5397
+ );
5398
+ if (!joined) {
5399
+ problem(
5400
+ ["dimension_groundings", dimensionId, "source"],
5401
+ "join_grounding_without_execution_path",
5402
+ `grounding for ${dimensionId} is a ${grounding.kind} against '${grounding.source}', which the concept does not declare among its sources \u2014 nothing performs that join, so the grounding must declare catalog_status 'unavailable' with its blocker (R11/G13)`
5403
+ );
5404
+ }
5405
+ }
5406
+ if (concept.materialization?.mode === "rollup" && concept.materialization.rollup_grain === void 0) {
5407
+ problem(["materialization", "rollup_grain"], "rollup_without_grain", "materialization mode 'rollup' must name its rollup_grain");
5408
+ }
5409
+ if (concept.materialization && concept.materialization.mode !== "rollup" && concept.materialization.rollup_grain !== void 0) {
5410
+ problem(["materialization", "rollup_grain"], "rollup_grain_without_rollup", "rollup_grain is only meaningful when mode is 'rollup'");
5411
+ }
5412
+ if (concept.family === "billing" && concept.livemode_qualified === void 0) {
5413
+ problem(["livemode_qualified"], "billing_concept_without_livemode", "a billing fact must state whether it is live/test qualified (R2)");
5414
+ }
5415
+ if (concept.livemode_qualified === true && concept.record_contract?.livemode_field === void 0) {
5416
+ problem(
5417
+ ["record_contract", "livemode_field"],
5418
+ "livemode_qualified_without_field",
5419
+ "livemode_qualified: true must name the field carrying Stripe live/test mode (R2)"
5420
+ );
5421
+ }
5422
+ for (const [index, key2] of (concept.keys ?? []).entries()) {
5423
+ if (key2.resolution === "absent" && !key2.note?.trim()) {
5424
+ problem(["keys", index, "note"], "absent_key_without_note", "resolution 'absent' must state why the key is not carried (R1)");
5425
+ }
5426
+ if (key2.resolution === "absent" && key2.path !== "-") {
5427
+ problem(["keys", index, "path"], "absent_key_with_path", "resolution 'absent' must carry path '-' \u2014 there is no path to read");
5428
+ }
5429
+ if (key2.resolution !== "absent" && key2.path === "-") {
5430
+ problem(["keys", index, "path"], "resolved_key_without_path", "a source or enriched key must name the path it resolves through");
5431
+ }
5432
+ }
5433
+ for (const duplicate of duplicates((concept.keys ?? []).map((key2) => key2.key))) {
5434
+ problem(["keys"], "duplicate_key", `duplicate entity key: ${duplicate}`);
5435
+ }
5436
+ for (const [index, measure] of (concept.measures ?? []).entries()) {
5437
+ if (measure.temporal === "stock" && measure.aggregation_semantics !== "semi_additive") {
5438
+ problem(
5439
+ ["measures", index, "aggregation_semantics"],
5440
+ "stock_measure_not_semi_additive",
5441
+ "a stock measure is semi_additive by construction \u2014 it is summed at one date, never across dates (R3)"
5442
+ );
5443
+ }
5444
+ if (measure.unit === "polymorphic") {
5445
+ if (!measure.note?.trim()) {
5446
+ problem(
5447
+ ["measures", index, "note"],
5448
+ "polymorphic_measure_without_note",
5449
+ "unit 'polymorphic' must name the column that decides the unit"
5450
+ );
5451
+ }
5452
+ if (measure.aggregation_semantics !== "non_additive") {
5453
+ problem(
5454
+ ["measures", index, "aggregation_semantics"],
5455
+ "polymorphic_measure_summed",
5456
+ "unit 'polymorphic' is non_additive \u2014 rows of different units cannot be summed"
5457
+ );
5458
+ }
5459
+ }
5460
+ if (measure.unit === "count" && measure.sign === "signed") {
5461
+ problem(["measures", index, "sign"], "signed_count_measure", "a count is unsigned; a signed change is a currency or usage delta (R3)");
5462
+ }
5463
+ }
5464
+ for (const duplicate of duplicates((concept.measures ?? []).map((measure) => measure.name))) {
5465
+ problem(["measures"], "duplicate_measure", `duplicate measure name: ${duplicate}`);
5466
+ }
5467
+ for (const duplicate of duplicates((concept.event_attributes ?? []).map((attribute) => attribute.name))) {
5468
+ problem(["event_attributes"], "duplicate_event_attribute", `duplicate event attribute: ${duplicate}`);
5469
+ }
5470
+ for (const [index, source] of (concept.sources ?? []).entries()) {
5471
+ if (source.kind === "unavailable" && !source.blocker?.trim()) {
5472
+ problem(["sources", index, "blocker"], "unavailable_source_without_blocker", "source kind 'unavailable' must name the blocker");
5473
+ }
5474
+ if (source.kind !== "unavailable" && source.blocker !== void 0) {
5475
+ problem(["sources", index, "blocker"], "available_source_with_blocker", "a real source carries no blocker \u2014 use note");
5476
+ }
5477
+ }
5478
+ const sourceKinds = new Set((concept.sources ?? []).map((source) => source.kind));
5479
+ if (sourceKinds.has("unavailable") && sourceKinds.size > 1) {
5480
+ problem(["sources"], "mixed_unavailable_sources", "a concept with an 'unavailable' source declares no other source");
5481
+ }
5482
+ if (sourceKinds.has("unavailable") && (concept.measures?.length ?? 0) > 0) {
5483
+ problem(["measures"], "unavailable_source_with_measures", "a concept with no fact declares no measures");
5484
+ }
5485
+ if (concept.build?.kind === "unavailable" && !concept.build.blocker?.trim()) {
5486
+ problem(["build", "blocker"], "unavailable_build_without_blocker", "build kind 'unavailable' must name the blocker");
5487
+ }
5488
+ if (concept.build && concept.build.kind !== "unavailable" && concept.build.blocker !== void 0) {
5489
+ problem(["build", "blocker"], "available_build_with_blocker", "a real build carries no blocker");
5490
+ }
5491
+ if (concept.maturity) {
5492
+ if (concept.maturity.status === "declared") {
5493
+ for (const field of ["horizon_field", "observed_through_field"]) {
5494
+ if (concept.maturity[field] === void 0) {
5495
+ problem(["maturity", field], "declared_maturity_missing_field", `maturity status 'declared' must name ${field} (R5)`);
5496
+ }
5497
+ }
5498
+ if (concept.maturity.blocker !== void 0) {
5499
+ problem(["maturity", "blocker"], "declared_maturity_with_blocker", "maturity status 'declared' carries no blocker");
5500
+ }
5501
+ } else if (!concept.maturity.blocker?.trim()) {
5502
+ problem(["maturity", "blocker"], "unavailable_maturity_without_blocker", "maturity status 'unavailable' must name the blocker");
5503
+ }
5504
+ }
5505
+ if (concept.oracle_family) {
5506
+ if (concept.oracle_family.family === "none" && !concept.oracle_family.blocker?.trim()) {
5507
+ problem(["oracle_family", "blocker"], "no_oracle_without_blocker", "oracle_family 'none' must name why no oracle covers this concept (R6)");
5508
+ }
5509
+ if (concept.oracle_family.family !== "none" && concept.oracle_family.blocker !== void 0) {
5510
+ problem(["oracle_family", "blocker"], "oracle_family_with_blocker", "a concept with an oracle family carries no blocker");
5511
+ }
5512
+ }
5513
+ for (const [index, dependency] of (concept.policy_dependencies ?? []).entries()) {
5514
+ if (!dependency.stamped && !dependency.blocker?.trim()) {
5515
+ problem(
5516
+ ["policy_dependencies", index, "blocker"],
5517
+ "unstamped_policy_without_blocker",
5518
+ `policy dependency '${dependency.kind}' is not stamped on the row, so it must name the blocker (R9)`
5519
+ );
5520
+ }
5521
+ if (dependency.stamped && dependency.blocker !== void 0) {
5522
+ problem(["policy_dependencies", index, "blocker"], "stamped_policy_with_blocker", "a stamped policy dependency carries no blocker");
5523
+ }
5524
+ }
5525
+ for (const duplicate of duplicates((concept.policy_dependencies ?? []).map((dependency) => dependency.kind))) {
5526
+ problem(["policy_dependencies"], "duplicate_policy_dependency", `duplicate policy dependency: ${duplicate}`);
5527
+ }
5528
+ const stampedPolicies = (concept.policy_dependencies ?? []).filter((dependency) => dependency.stamped);
5529
+ if (stampedPolicies.length > 0 && (concept.record_contract?.policy_version_fields?.length ?? 0) === 0) {
5530
+ problem(
5531
+ ["record_contract", "policy_version_fields"],
5532
+ "stamped_policy_without_version_field",
5533
+ "a stamped policy dependency requires the record contract to name the policy-version field it is stamped in (R2)"
5534
+ );
5535
+ }
5536
+ }
5537
+ ).meta(meta("AnalyticsCatalogConceptValidated"));
5089
5538
  var OrderBy = z14.strictObject({
5090
5539
  field: SemanticIdField.meta(Unrestricted11),
5091
5540
  direction: z14.enum(["asc", "desc"]).meta(Unrestricted11)
@@ -5526,7 +5975,29 @@ var placementLifecycleBase = {
5526
5975
  surface_slot_id: nstr(),
5527
5976
  payload_id: nstr(),
5528
5977
  decision_id: nstr(),
5529
- decision_source: nstr()
5978
+ decision_source: nstr(),
5979
+ /**
5980
+ * The `unique_handle` of the entitlement rule / placement rule whose verdict
5981
+ * this event records; `null` when no rule matched (BL-0062, worksheet gap
5982
+ * G3). On the placement lane this is the winning placement entry's handle —
5983
+ * `PlacementOutput.rule_id`, already selected by the local resolver and
5984
+ * discarded at emit until now.
5985
+ *
5986
+ * Optional AND nullable, unlike the required-but-nullable siblings around
5987
+ * it. Every SDK already in the field emits these payloads without the key,
5988
+ * and `quarantineVerdict` (revturbine-web `src/lib/events/ingest-validation.ts`)
5989
+ * holds platform event names to this contract at the ingest boundary — a
5990
+ * required field would quarantine live telemetry from every deployed SDK.
5991
+ * So: absent = a pre-BL-0062 producer, `null` = no rule matched, string =
5992
+ * the winning rule.
5993
+ *
5994
+ * Why it rides the whole lifecycle base rather than `placement_resolved`
5995
+ * alone: attribution reads the exposure and outcome facts and stamps the
5996
+ * winning touch's keys onto the movement — "through it rule `usage_70pct`"
5997
+ * in the metric-primitives worked example (§3), anchored `touch_time` per
5998
+ * its R8. An exposure without the rule key cannot carry that.
5999
+ */
6000
+ rule_handle: onstr()
5530
6001
  };
5531
6002
  var PlacementLifecyclePayload = z18.looseObject({ ...placementLifecycleBase });
5532
6003
  var PlacementExposedPayload = z18.looseObject({
@@ -5551,7 +6022,29 @@ var PlacementInteractionPayload = z18.looseObject({
5551
6022
  // explicit null when the caller supplied no timestamp (plan 228 TASK-4 —
5552
6023
  // the typed emit surface surfaced the mismatch; the wire is the truth).
5553
6024
  interaction_at: onstr(),
5554
- remind_after_seconds: z18.number().optional().meta(Unrestricted15)
6025
+ remind_after_seconds: z18.number().optional().meta(Unrestricted15),
6026
+ /**
6027
+ * The `unique_handle` of the placement rule whose treatment the user acted
6028
+ * on — `PlacementOutput.rule_id`, the same value the lifecycle lane stamps
6029
+ * (BL-0182, follow-up to BL-0062 / #389).
6030
+ *
6031
+ * `placement_interaction` does not share `placementLifecycleBase` — its
6032
+ * placement keys are all optional, because a bare `trackInteraction` caller
6033
+ * may supply none of them — so #389's single edit to that base reached the
6034
+ * four lifecycle, five slot and three gate events and left the interaction
6035
+ * lane without a rule key. The effect was a funnel sliceable by rule at
6036
+ * exposure and at outcome but NOT at the click in between, which is the
6037
+ * step the CTR and click-through primitives are computed over.
6038
+ *
6039
+ * Same contract as #389 and for the same reason: optional AND nullable.
6040
+ * revturbine-web's `quarantineVerdict`
6041
+ * (`src/lib/events/ingest-validation.ts`) holds platform event names to
6042
+ * these contracts at the ingest boundary, so a required field would
6043
+ * quarantine live interaction telemetry from every SDK already deployed.
6044
+ * Absent = a pre-BL-0182 producer or an interaction with no decision in
6045
+ * scope, `null` = no rule matched, string = the winning rule.
6046
+ */
6047
+ rule_handle: onstr()
5555
6048
  });
5556
6049
  var GateEvaluatedPayloadSchema = z18.looseObject({
5557
6050
  entitlement_handle: str(),
@@ -5560,13 +6053,38 @@ var GateEvaluatedPayloadSchema = z18.looseObject({
5560
6053
  reason: nstr(),
5561
6054
  limit: nnum(),
5562
6055
  used: nnum(),
5563
- remaining: nnum()
6056
+ remaining: nnum(),
6057
+ /**
6058
+ * The `unique_handle` of the entitlement rule whose verdict this event
6059
+ * records; `null` when no rule matched (BL-0062, worksheet gap G3).
6060
+ *
6061
+ * This is the rule the §2.6.5 most-permissive selection picked — the one
6062
+ * whose `limit` / `enabled` / `allowance_value` produced the `outcome`
6063
+ * beside it. Without it the event says WHAT was decided and never WHICH
6064
+ * configured rule decided it, which is the rule slice, and the
6065
+ * `rule_handle → objective` hop the objective slice needs (BL-0065).
6066
+ *
6067
+ * Optional as well as nullable — see `placementLifecycleBase.rule_handle`
6068
+ * for why a required field would quarantine every deployed SDK's gate
6069
+ * telemetry at the ingest boundary.
6070
+ */
6071
+ rule_handle: onstr()
5564
6072
  }).meta(meta4("GateEvaluatedPayload"));
5565
6073
  var GateAttemptedPayload = z18.looseObject({ entitlement_handle: str() });
5566
- var GateAllowedPayload = z18.looseObject({ entitlement_handle: str() });
6074
+ var GateAllowedPayload = z18.looseObject({
6075
+ entitlement_handle: str(),
6076
+ /** The rule that granted. See `GateEvaluatedPayloadSchema.rule_handle`. */
6077
+ rule_handle: onstr()
6078
+ });
5567
6079
  var GateDeniedPayload = z18.looseObject({
5568
6080
  entitlement_handle: str(),
5569
- reason: onstr()
6081
+ reason: onstr(),
6082
+ /**
6083
+ * The rule that denied — `null` on the two identity-shaped denials
6084
+ * (`no_plan_identity`, `no_matching_entitlement_rule`), where no rule
6085
+ * matched at all. See `GateEvaluatedPayloadSchema.rule_handle`.
6086
+ */
6087
+ rule_handle: onstr()
5570
6088
  });
5571
6089
  var slotContextBase = {
5572
6090
  surface_slot_id: nstr(),
@@ -5577,7 +6095,18 @@ var slotContextBase = {
5577
6095
  category: nstr(),
5578
6096
  decision_source: nstr(),
5579
6097
  reason_codes: z18.array(z18.string()).meta(Unrestricted15),
5580
- decision_id: nstr()
6098
+ decision_id: nstr(),
6099
+ /**
6100
+ * The placement rule that won this slot — `PlacementOutput.rule_id`, in
6101
+ * scope in `slotContext()` and dropped there until now (BL-0062, gap G3).
6102
+ *
6103
+ * Slot events are a verdict lane, not merely a render log: `slot_filled`
6104
+ * says a rule won, `slot_suppressed` / `slot_empty` say none did. Carrying
6105
+ * the handle is what lets "which rule keeps losing its slot" be asked at
6106
+ * all — `reason_codes` beside it names WHY, never WHOSE. `null` on the
6107
+ * terminal-empty events, absent on a pre-BL-0062 producer.
6108
+ */
6109
+ rule_handle: onstr()
5581
6110
  };
5582
6111
  var SlotLifecyclePayload = z18.looseObject({ ...slotContextBase });
5583
6112
  var SlotErrorPayload = z18.looseObject({
@@ -5638,6 +6167,51 @@ var MilestonePayload = z18.looseObject({});
5638
6167
  var AcquisitionMilestonePayload = z18.looseObject({
5639
6168
  acquisition_source: z18.string().optional().meta(Unrestricted15)
5640
6169
  });
6170
+ var LifecycleEvidence = z18.looseObject({
6171
+ kind: z18.enum(["provider_fact", "app_fact", "usage_exhaustion"]).meta(Unrestricted15),
6172
+ /** The producer-scoped reference to the proving fact (a Stripe event id, an
6173
+ * app write id, a metering observation id). Opaque to RevTurbine. */
6174
+ ref: str()
6175
+ });
6176
+ var AccountCreatedPayload = z18.looseObject({
6177
+ acquisition_source: z18.string().optional().meta(Unrestricted15),
6178
+ /** The account grain (plan 276 R-2) — never a user id. */
6179
+ account_id: onstr(),
6180
+ /** When the account came into existence, per the producer. */
6181
+ created_at: onstr(),
6182
+ /** What kind of act created it (e.g. `self_serve_signup`, `invite_accepted`,
6183
+ * `provisioned`, `import`). A USER signup is not account creation (R-2). */
6184
+ source: onstr(),
6185
+ evidence: LifecycleEvidence.nullable().optional()
6186
+ });
6187
+ var TrialRevisionPayload = z18.looseObject({
6188
+ /** Stable id for the episode this revision belongs to. Conversion links only
6189
+ * its OWN episode (plan 276 AC-4), so this is half the identity key. */
6190
+ trial_episode_id: str(),
6191
+ /** The account the episode is materialized against (R-2). */
6192
+ account_id: str(),
6193
+ /** `user` retains a user-subject episode WITHOUT making it an account-wide
6194
+ * grant (R-2); `account` is an account-wide grant. */
6195
+ subject_scope: z18.enum(["account", "user"]).meta(Unrestricted15),
6196
+ /** The Playbook trial rule's `unique_handle`, when the episode came from one. */
6197
+ rule_handle: onstr(),
6198
+ /** The trialed plan's handle, when the episode grants one plan. */
6199
+ plan_handle: onstr(),
6200
+ /** `free_trial` / `reverse_trial` / anything the tenant runs. Vocabulary only. */
6201
+ trial_type: onstr(),
6202
+ revision: z18.enum(["started", "extended", "converted", "reverted", "expired", "revoked"]).meta(Unrestricted15),
6203
+ /** When the revision took effect, per the proving fact — never the write clock. */
6204
+ effective_at: str(),
6205
+ /** The declared end. Its passing proves only that the time passed (R-1(c)). */
6206
+ scheduled_end_at: onstr(),
6207
+ /** The EVIDENCED end. Null until a fact closes the episode. */
6208
+ actual_end_at: onstr(),
6209
+ evidence: LifecycleEvidence,
6210
+ /** The biller's reference when a provider fact is involved (e.g. a Stripe
6211
+ * subscription id), so an app fact and a provider derivative about the same
6212
+ * episode can be reconciled. */
6213
+ provider_ref: onstr()
6214
+ });
5641
6215
  var UsageRecordedPayload = z18.looseObject({
5642
6216
  metered_units: z18.number().nonnegative().meta(Unrestricted15),
5643
6217
  included_allowance: z18.number().nonnegative().meta(Unrestricted15),
@@ -5775,7 +6349,7 @@ var EVENT_PAYLOAD_CONTRACTS = {
5775
6349
  user_context_observed: { schema: UserContextObservedPayload, identity: envelopeIdentity },
5776
6350
  clickstream_page_view: { schema: PageViewPayload, identity: envelopeIdentity },
5777
6351
  // SDK client — customer-product lifecycle milestones (promoted, R-1)
5778
- account_created: { schema: AcquisitionMilestonePayload, identity: envelopeIdentity },
6352
+ account_created: { schema: AccountCreatedPayload, identity: envelopeIdentity },
5779
6353
  user_signed_up: { schema: AcquisitionMilestonePayload, identity: envelopeIdentity },
5780
6354
  onboarding_completed: { schema: MilestonePayload, identity: envelopeIdentity },
5781
6355
  account_activated: { schema: MilestonePayload, identity: envelopeIdentity },
@@ -5785,6 +6359,10 @@ var EVENT_PAYLOAD_CONTRACTS = {
5785
6359
  plan_viewed: { schema: PlanViewedPayload, identity: envelopeIdentity },
5786
6360
  promotion_applied: { schema: PromotionAppliedPayload, identity: envelopeIdentity },
5787
6361
  promotion_converted: { schema: PromotionConvertedPayload, identity: envelopeIdentity },
6362
+ // App-owned trial episode (plan 276 R-1(b)) — identity is the episode plus
6363
+ // the revision, so a retry of the same app fact collapses while a later
6364
+ // revision of the same episode stays a distinct occurrence.
6365
+ trial_revision: { schema: TrialRevisionPayload, identity: ["trial_episode_id", "revision"] },
5788
6366
  // SDK server — derived observations
5789
6367
  growth_signal_observed: { schema: GrowthSignalObservedPayload, identity: envelopeIdentity },
5790
6368
  // SDK meta lane
@@ -5904,7 +6482,15 @@ var SDK_CLIENT_EVENT_NAMES = [
5904
6482
  "usage_recorded",
5905
6483
  "plan_viewed",
5906
6484
  "promotion_applied",
5907
- "promotion_converted"
6485
+ "promotion_converted",
6486
+ // App-owned trial-episode revision (plan 276 R-1(b), TASK-12/TASK-13; ruling
6487
+ // D-21). The authoritative fact for an APP-RUN trial: the tenant's product
6488
+ // owns trial execution, so only the tenant's product can state that an
6489
+ // episode started, was extended, converted, reverted, expired or was
6490
+ // revoked. Distinct from the `trial_*` billing band, which is the provider
6491
+ // derivative (R-1(a)) and cannot establish enrolment. The clock is never an
6492
+ // authority (R-1(c)): an elapsed scheduled end emits nothing at all.
6493
+ "trial_revision"
5908
6494
  ];
5909
6495
  var SDK_SERVER_EVENT_NAMES = [
5910
6496
  "growth_signal_observed"
@@ -5985,7 +6571,7 @@ var EVENT_METADATA = {
5985
6571
  sdk_validation_warning: { purpose: "The SDK found a config or usage problem worth surfacing.", stability: "internal" },
5986
6572
  resolution_failure: { purpose: "A decision produced nothing; allow-listed handles and closed reason codes only.", stability: "internal" },
5987
6573
  // Customer-product lifecycle milestones (plan 228 TASK-2, promoted under R-1)
5988
- account_created: { purpose: "A customer account came into existence in the tenant product; acquisition_source rides on the payload.", stability: "stable" },
6574
+ account_created: { purpose: "A customer account came into existence in the tenant product; acquisition_source rides on the payload, and the plan-276 REQ-3 account-creation evidence (account_id, created_at, source, evidence) rides on it when the producer holds it.", stability: "stable" },
5989
6575
  user_signed_up: { purpose: "A user completed signup in the tenant product.", stability: "stable" },
5990
6576
  onboarding_completed: { purpose: "A user finished the tenant product onboarding flow.", stability: "stable" },
5991
6577
  account_activated: { purpose: "An account reached the tenant-defined activation milestone.", stability: "stable" },
@@ -5995,6 +6581,7 @@ var EVENT_METADATA = {
5995
6581
  plan_viewed: { purpose: "A user viewed plan or pricing content; plan_handle and entry_tier ride on the payload.", stability: "stable" },
5996
6582
  promotion_applied: { purpose: "A promotion was applied to an account; promotion_handle rides on the payload.", stability: "stable" },
5997
6583
  promotion_converted: { purpose: "An account with a promotion converted; full_price marks conversions where the discount lapsed first.", stability: "stable" },
6584
+ trial_revision: { purpose: "The app-owned authoritative fact about one trial episode (plan 276 R-1(b)): which revision occurred (started/extended/converted/reverted/expired/revoked), when it took effect, and the evidence that proves it. Trial execution and ownership stay with the customer app.", stability: "stable" },
5998
6585
  // Server-derived observations (plan 228 TASK-2)
5999
6586
  growth_signal_observed: { purpose: "A pre-computed windowed metric observation written by a server-side producer; readers should prefer read-time derivation where a platform source exists.", stability: "internal" },
6000
6587
  // Billing lifecycle (plan 228 R-2) — dual-source: Stripe webhook processor OR typed SDK emit.
@@ -6373,7 +6960,29 @@ var EventEnvelopeSchema = IdField.extend({
6373
6960
  event_type: z24.string().min(1).meta(Unrestricted20),
6374
6961
  source: EventSourceSchema.default("sdk").meta(Unrestricted20),
6375
6962
  tenant_id: z24.string().min(1).optional().meta(Unrestricted20),
6376
- user_id: z24.string().min(1).optional().meta(Pii4),
6963
+ /**
6964
+ * Identity keys are `Unrestricted` on EVERY lane (BL-0131, Kent's **D-19**,
6965
+ * 2026-09-25): *"Not treated as PII, but a PII leak still detectable. We
6966
+ * want our customers to use UUIDs, but if they pass an email, we need to
6967
+ * hash it consistently."*
6968
+ *
6969
+ * A `user_id` / `account_id` is the TENANT'S OWN primary key, not personal
6970
+ * data. Before BL-0131 the three ingest lanes disagreed — `TrackEvent.user_id`
6971
+ * and `TreatmentInteractionInput.account_id` were `Pii` while
6972
+ * `TrackEvent.account_id` was `Unrestricted` — so the same logical key
6973
+ * carried two classifications depending on which route it arrived through.
6974
+ *
6975
+ * The leak is handled at the BOUNDARY, not by the classification:
6976
+ * `classifyIdentityValue` / `normalizeIdentityValue` in `@revt-eng/core`
6977
+ * detect a PII-shaped value, consistently hash an email-shaped one
6978
+ * (`eml_<sha256-16>`, unsalted so every writer and reader agrees), and
6979
+ * report `identity_pii_detected` so the tenant learns to send UUIDs.
6980
+ *
6981
+ * Dropping `pii` does NOT widen browser exposure: the plan-157
6982
+ * client-context filter is an allowlist keyed on field-level `external`
6983
+ * exposure, which these fields do not carry, so they stay `server_only`.
6984
+ */
6985
+ user_id: z24.string().min(1).optional().meta(Unrestricted20),
6377
6986
  session_id: z24.string().min(1).optional().meta(Pii4),
6378
6987
  occurred_at: z24.string().datetime().meta(Unrestricted20),
6379
6988
  request_id: z24.string().min(1).meta(Unrestricted20),
@@ -6401,7 +7010,9 @@ var EventIngestBatchSchema = z24.array(
6401
7010
  event_type: z24.string().min(1).meta(Unrestricted20),
6402
7011
  occurred_at: z24.string().datetime().optional().meta(Unrestricted20),
6403
7012
  tenant_id: z24.string().min(1).optional().meta(Unrestricted20),
6404
- user_id: z24.string().min(1).optional().meta(Pii4),
7013
+ // Identity key — `Unrestricted` on every lane (BL-0131, D-19); see
7014
+ // `EventEnvelopeSchema.user_id`.
7015
+ user_id: z24.string().min(1).optional().meta(Unrestricted20),
6405
7016
  session_id: z24.string().min(1).optional().meta(Pii4),
6406
7017
  attributes: z24.record(z24.string(), z24.unknown()).optional().meta(Unrestricted20),
6407
7018
  payload: z24.record(z24.string(), z24.unknown()).optional().meta(Unrestricted20),
@@ -6431,7 +7042,9 @@ var TreatmentInteractionTypeSchema = z24.enum([
6431
7042
  }
6432
7043
  );
6433
7044
  var TreatmentInteractionInputSchema = z24.object({
6434
- user_id: z24.string().min(1).meta(Pii4),
7045
+ // Identity key — `Unrestricted` on every lane (BL-0131, D-19); see
7046
+ // `EventEnvelopeSchema.user_id` for the full rationale.
7047
+ user_id: z24.string().min(1).meta(Unrestricted20),
6435
7048
  /**
6436
7049
  * The account / organization the user acted on behalf of (plan 232 REQ-4).
6437
7050
  *
@@ -6446,8 +7059,13 @@ var TreatmentInteractionInputSchema = z24.object({
6446
7059
  * Optional, and the route keeps the `user_id` fallback when it is absent,
6447
7060
  * so pre-232 SDKs behave exactly as before rather than writing an empty
6448
7061
  * key that the `account_id != ''` predicates would silently drop.
7062
+ *
7063
+ * BL-0131 (D-19) aligned this to `Unrestricted`, matching the clickstream
7064
+ * half (`TrackEvent.account_id`): an account id is a join key, not
7065
+ * personal data, and a PII-shaped value is detected and consistently
7066
+ * hashed at the ingest boundary instead of being re-labelled here.
6449
7067
  */
6450
- account_id: z24.string().min(1).optional().meta(Pii4),
7068
+ account_id: z24.string().min(1).optional().meta(Unrestricted20),
6451
7069
  placement_id: z24.string().min(1).meta(Unrestricted20),
6452
7070
  treatment_id: z24.string().min(1).optional().meta(Unrestricted20),
6453
7071
  // Presentation context (plan 114) — carried so a treatment interaction can
@@ -6473,6 +7091,30 @@ var TreatmentInteractionInputSchema = z24.object({
6473
7091
  message_block_id: z24.string().min(1).optional().meta(Unrestricted20),
6474
7092
  experiment_id: z24.string().min(1).optional().meta(Unrestricted20),
6475
7093
  variant_key: z24.string().min(1).optional().meta(Unrestricted20),
7094
+ /**
7095
+ * The `unique_handle` of the rule whose treatment was presented (BL-0200).
7096
+ *
7097
+ * #391 put `rule_handle` on the clickstream `placement_interaction` EVENT
7098
+ * and deliberately kept it off this wire, so the BASE
7099
+ * `placement_exposure_attribution` row this contract writes had no rule and
7100
+ * `treatment-interaction-rows.ts` hard-coded `rule_handle: null`. Only an
7101
+ * exposure that an attributed conversion later enriched carried one, which
7102
+ * is why the attribution plane's rule cut could serve conversion-grain
7103
+ * metrics and had to refuse presentation-grain ones BY NAME: computed over
7104
+ * the attributed subset, `presented_accounts` would report the accounts
7105
+ * that converted and `conversion.rate` ~100%.
7106
+ *
7107
+ * Carrying it here makes the rule a property of the EXPOSURE, known at
7108
+ * presentation time by the only party that holds the resolved
7109
+ * `PlacementOutput`, so those cuts become honestly servable.
7110
+ *
7111
+ * Optional AND nullable, the same contract #389/#391 chose and for the same
7112
+ * reason: a required field would reject every already-deployed SDK's
7113
+ * interactions at the ingest boundary. Absent = a pre-BL-0200 producer or
7114
+ * an interaction with no decision in scope, `null` = no rule matched, a
7115
+ * string = the winning rule.
7116
+ */
7117
+ rule_handle: z24.string().min(1).nullable().optional().meta(Unrestricted20),
6476
7118
  /**
6477
7119
  * Caller-declared test traffic (plan 164) — mirrors `TrackEvent.test` so
6478
7120
  * the presentation/interaction feed (the dashboard denominators) carries
@@ -6673,7 +7315,9 @@ var EventOriginSchema = z24.enum(["explicit", "automatic", "derived", "raw"]).me
6673
7315
  );
6674
7316
  var TrackEventSchema = z24.object({
6675
7317
  environment_id: z24.string().min(1).meta(Unrestricted20),
6676
- user_id: z24.string().min(1).meta(Pii4),
7318
+ // Identity key — `Unrestricted` on every lane (BL-0131, D-19); see
7319
+ // `EventEnvelopeSchema.user_id` for the full rationale.
7320
+ user_id: z24.string().min(1).meta(Unrestricted20),
6677
7321
  /**
6678
7322
  * The account / organization the user acted on behalf of (BL-0117).
6679
7323
  *
@@ -7388,6 +8032,39 @@ var TrialInstanceSchema = IdField.merge(TimestampFields).merge(TenantIdField).ex
7388
8032
  usage_limit_value: z27.number().int().min(1).optional().meta(Unrestricted21),
7389
8033
  converted_at: NullableDatetimeField.meta(Unrestricted21),
7390
8034
  cancelled_at: NullableDatetimeField.meta(Unrestricted21),
8035
+ /**
8036
+ * Stable identity of the trial EPISODE this row represents
8037
+ * (BL-0247 / plan 276 TASK-12). A customer can run more than one
8038
+ * trial, and a subscription can re-enter `trialing`, so the
8039
+ * (customer, subscription) pair does not name an occurrence. The
8040
+ * billing lane already discriminates an episode by the Stripe
8041
+ * subscription id plus its `trial_start`; this column persists
8042
+ * that same discriminator so a fact about one episode can be
8043
+ * written to exactly that episode's row.
8044
+ *
8045
+ * Nullable: rows created before the column existed, and rows
8046
+ * created by paths that have no Stripe evidence to mint an id
8047
+ * from, carry NULL. NULL means "this episode has no first-class
8048
+ * identity", never "episode zero" — and Postgres treats NULLs as
8049
+ * distinct, so the composite UNIQUE below does not collapse them.
8050
+ */
8051
+ trial_episode_id: z27.string().min(1).nullable().optional().meta(Unrestricted21),
8052
+ /**
8053
+ * The Stripe subscription this episode belongs to — the binding
8054
+ * that lets a Stripe fact resolve one episode without guessing
8055
+ * (BL-0245 carried this in `metadata.stripe_subscription_id`).
8056
+ * Nullable because a trial need not originate from Stripe at all.
8057
+ */
8058
+ stripe_subscription_id: z27.string().min(1).nullable().optional().meta(Unrestricted21),
8059
+ /**
8060
+ * The EVIDENCED end of the episode: the moment a provider fact
8061
+ * said the trial actually stopped. Distinct from `expires_at`,
8062
+ * which is the SCHEDULED end and must stay distinguishable
8063
+ * (plan 276 R-1 — no clock authority; an elapsed `expires_at`
8064
+ * with no fact behind it is not an end). Null until a fact
8065
+ * proves one.
8066
+ */
8067
+ actual_end_at: NullableDatetimeField.meta(Unrestricted21),
7391
8068
  metadata: MetadataField.meta(Unrestricted21)
7392
8069
  }).meta(
7393
8070
  { id: "TrialInstance", "x-revturbine-schema-persistence": Persisted13, "x-revturbine-schema-exposure": Internal17 }
@@ -7590,7 +8267,12 @@ var trialPaths = {
7590
8267
  summary: "List trial instances",
7591
8268
  tags: ["trials"],
7592
8269
  responses: { "200": { description: "Trial instance list", content: { "application/json": { schema: ListEnvelope(TrialInstanceSchema) } } } },
7593
- "x-revturbine-operation": { exposure: "internal", resource: "trial-instances", persistence: { table: "trialInstances", mode: "list" } }
8270
+ // One row per trial EPISODE (BL-0247 / plan 276 TASK-12). This is the
8271
+ // ON CONFLICT target the Stripe binding writer upserts against, so a
8272
+ // re-delivered `customer.subscription.*` event updates the episode it
8273
+ // already created instead of minting a duplicate. NULL episode ids are
8274
+ // distinct in Postgres, so pre-existing and non-Stripe rows are unaffected.
8275
+ "x-revturbine-operation": { exposure: "internal", resource: "trial-instances", persistence: { table: "trialInstances", mode: "list", uniqueBy: ["tenant_id", "customer_id", "trial_episode_id"] } }
7594
8276
  })
7595
8277
  },
7596
8278
  "/api/trials/instances/{instanceId}": {
@@ -8597,6 +9279,16 @@ var PersonalizationTokenSchema = IdField.merge(TimestampFields).merge(TenantIdFi
8597
9279
  { id: "PersonalizationToken", "x-revturbine-schema-persistence": Persisted16, "x-revturbine-schema-exposure": Internal20, ...PENDING_PLAYBOOK_FACETS4, ...namedIdentity() }
8598
9280
  );
8599
9281
  var PersonalizationTokenAnchorSchema = makeAnchor("PersonalizationTokenAnchor");
9282
+ var ObjectiveSchema = IdField.merge(TimestampFields).merge(TenantIdField).merge(AnchorFields).merge(VersionFields).extend({
9283
+ anchor_id: z30.string().min(1).meta({ ...Unrestricted24, readOnly: true }),
9284
+ handle: HandleField.meta(Unrestricted24),
9285
+ name: NameField.meta(Unrestricted24),
9286
+ description: DescriptionField.meta(Unrestricted24),
9287
+ metadata: MetadataField.meta(Unrestricted24)
9288
+ }).meta(
9289
+ { id: "Objective", "x-revturbine-schema-persistence": Persisted16, "x-revturbine-schema-exposure": Internal20, ...PLAYBOOK_AUTHORING_FACETS2, ...namedIdentity() }
9290
+ );
9291
+ var ObjectiveAnchorSchema = makeAnchor("ObjectiveAnchor");
8600
9292
  var OnboardingStateSchema = z30.enum(["not_started", "started", "details_submitted", "charges_enabled", "activated", "deauthorized"]).meta({ id: "OnboardingState", "x-revturbine-schema-persistence": Transient24, "x-revturbine-schema-exposure": Internal20 });
8601
9293
  var StripeIntegrationConfigSchema = IdField.merge(TimestampFields).merge(TenantIdField).merge(AnchorFields).merge(VersionFields).extend({
8602
9294
  handle: HandleField.meta({ ...Unrestricted24, readOnly: true }),
@@ -8914,7 +9606,9 @@ var RevTurbineConfigEntitlementRulesItemSchema = z30.object({
8914
9606
  }).shape,
8915
9607
  current_usage: z30.number().default(0).meta(Unrestricted24),
8916
9608
  /** How usage is partitioned across the identity hierarchy. */
8917
- allocation: UsageAllocationSchema.optional().meta(Unrestricted24)
9609
+ allocation: UsageAllocationSchema.optional().meta(Unrestricted24),
9610
+ /** Optional business objective, by `objectives[].handle` (VAL-OBJ-01). Config-only. */
9611
+ objective: ObjectiveField.meta(Unrestricted24)
8918
9612
  }).meta(
8919
9613
  { id: "RevTurbineConfigEntitlementRulesItem", "x-revturbine-schema-persistence": Transient24, "x-revturbine-schema-exposure": External12, ...PLAYBOOK_SDK_FACETS8 }
8920
9614
  );
@@ -9010,6 +9704,13 @@ var RevTurbineConfigPersonalizationTokensItemSchema = z30.object({
9010
9704
  }).meta(
9011
9705
  { id: "RevTurbineConfigPersonalizationTokensItem", "x-revturbine-schema-persistence": Transient24, "x-revturbine-schema-exposure": External12, ...PLAYBOOK_SDK_FACETS8 }
9012
9706
  );
9707
+ var RevTurbineConfigObjectivesItemSchema = z30.object({
9708
+ handle: HandleField.meta(Unrestricted24),
9709
+ name: NameField.meta(Unrestricted24),
9710
+ description: DescriptionField.meta(Unrestricted24)
9711
+ }).meta(
9712
+ { id: "RevTurbineConfigObjectivesItem", "x-revturbine-schema-persistence": Transient24, "x-revturbine-schema-exposure": External12, ...PLAYBOOK_AUTHORING_FACETS2 }
9713
+ );
9013
9714
  var MessageBlockContentSchema = z30.object({
9014
9715
  header: z30.string().optional().meta(Unrestricted24),
9015
9716
  body: z30.string().optional().meta(Unrestricted24),
@@ -9113,7 +9814,9 @@ var RevTurbineConfigPlacementItemSchema = z30.object({
9113
9814
  category: RevTurbineConfigPlacementCategorySchema.meta(Unrestricted24),
9114
9815
  trigger: RevTurbineConfigPlacementTriggerSchema.meta(Unrestricted24),
9115
9816
  payloads: z30.array(RevTurbineConfigStudioPayloadSchema).meta(Unrestricted24),
9116
- order: z30.number().int().min(0).meta(Unrestricted24)
9817
+ order: z30.number().int().min(0).meta(Unrestricted24),
9818
+ /** Optional business objective, by `objectives[].handle` (VAL-OBJ-01). Config-only. */
9819
+ objective: ObjectiveField.meta(Unrestricted24)
9117
9820
  }).meta(
9118
9821
  { id: "RevTurbineConfigPlacementItem", "x-revturbine-schema-persistence": Transient24, "x-revturbine-schema-exposure": External12, ...PLAYBOOK_SDK_FACETS8 }
9119
9822
  );
@@ -9207,6 +9910,14 @@ var PlaybookBodySchema = z30.object({
9207
9910
  */
9208
9911
  free_trial_rules: z30.array(RevTurbineConfigFreeTrialRuleItemSchema).optional().meta({ ...Unrestricted24, ...PLAYBOOK_AUTHORING_FACETS2 }),
9209
9912
  reverse_trial_rules: z30.array(RevTurbineConfigReverseTrialRuleItemSchema).optional().meta({ ...Unrestricted24, ...PLAYBOOK_AUTHORING_FACETS2 }),
9913
+ /**
9914
+ * Business objectives (BL-0176 / BL-0178, ruling D-15). Optional so every
9915
+ * Playbook authored before objectives existed still parses. Placements and
9916
+ * entitlement rules reference an entry by `handle` through their optional
9917
+ * `objective` field. Authoring-only: no runtime decision reads it, so it is
9918
+ * not an SDK input and never lowers into the IR.
9919
+ */
9920
+ objectives: z30.array(RevTurbineConfigObjectivesItemSchema).optional().meta({ ...Unrestricted24, ...PLAYBOOK_AUTHORING_FACETS2 }),
9210
9921
  // Plan / add-on variation prices carried by handle (plan 118 TASK-16). These
9211
9922
  // live on the legacy schema (not just the canonical Playbook body) so that a
9212
9923
  // legacy `version`-shaped config — the shape the demo-data configs and the
@@ -9473,6 +10184,56 @@ var configPaths = {
9473
10184
  "x-revturbine-operation": { exposure: "internal", resource: "personalization-tokens", persistence: { table: "personalizationTokenVersions", mode: "delete" } }
9474
10185
  })
9475
10186
  },
10187
+ "/api/objective-anchors": {
10188
+ get: operation({
10189
+ operationId: "listObjectiveAnchors",
10190
+ requestParams: { query: ListQueryParamsSchema },
10191
+ summary: "List objective anchors (identity registry)",
10192
+ tags: ["config"],
10193
+ responses: {
10194
+ "200": { description: "Objective anchor list", content: { "application/json": { schema: ListEnvelope(ObjectiveAnchorSchema) } } },
10195
+ default: { description: "Error response", content: { "application/json": { schema: ErrorEnvelope } } }
10196
+ },
10197
+ "x-revturbine-operation": { exposure: "internal", resource: "objective-anchors", persistence: { table: "objectives", mode: "list" } }
10198
+ })
10199
+ },
10200
+ "/api/config/objectives": {
10201
+ get: operation({
10202
+ operationId: "listObjectives",
10203
+ requestParams: { query: ListQueryParamsSchema },
10204
+ summary: "List objectives",
10205
+ tags: ["config"],
10206
+ responses: { "200": { description: "Objective list", content: { "application/json": { schema: ListEnvelope(ObjectiveSchema) } } } },
10207
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "list" } }
10208
+ }),
10209
+ post: operation({
10210
+ operationId: "createObjective",
10211
+ summary: "Create objective",
10212
+ tags: ["config"],
10213
+ requestBody: { required: true, content: { "application/json": { schema: toCreateSchema(ObjectiveSchema) } } },
10214
+ responses: { "201": { description: "Created", content: { "application/json": { schema: ObjectiveSchema } } } },
10215
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "create" } }
10216
+ })
10217
+ },
10218
+ "/api/config/objectives/{id}": {
10219
+ patch: operation({
10220
+ operationId: "updateObjective",
10221
+ requestParams: { path: z30.object({ id: z30.string() }) },
10222
+ summary: "Update objective",
10223
+ tags: ["config"],
10224
+ requestBody: { required: true, content: { "application/json": { schema: ObjectiveSchema.partial() } } },
10225
+ responses: { "200": { description: "Updated", content: { "application/json": { schema: ObjectiveSchema } } } },
10226
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "update" } }
10227
+ }),
10228
+ delete: operation({
10229
+ operationId: "deleteObjective",
10230
+ requestParams: { path: z30.object({ id: z30.string() }) },
10231
+ summary: "Delete objective",
10232
+ tags: ["config"],
10233
+ responses: { "204": { description: "Deleted" } },
10234
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "delete" } }
10235
+ })
10236
+ },
9476
10237
  "/api/config/stripe": {
9477
10238
  get: operation({
9478
10239
  operationId: "getStripeIntegrationConfig",
@@ -9925,6 +10686,14 @@ var EntitlementCheckResultSchema = z34.object({
9925
10686
  used: z34.number().optional().meta(Unrestricted28),
9926
10687
  /** `max(0, limit - used)`. */
9927
10688
  remaining: z34.number().optional().meta(Unrestricted28),
10689
+ /**
10690
+ * The `unique_handle` of the entitlement rule this verdict came from —
10691
+ * the winner of the §2.6.5 most-permissive selection (BL-0062, gap G3).
10692
+ * Absent when no rule produced the verdict (unknown handle, no plan
10693
+ * identity, default policy). This is the value an SDK stamps as the
10694
+ * gate event's `rule_handle`, grounding the analytics rule slice.
10695
+ */
10696
+ rule_handle: z34.string().optional().meta(Unrestricted28),
9928
10697
  /** Upsell placement to render when entitlement is denied. */
9929
10698
  placement: PlacementDecisionOutputSchema.optional().meta(Unrestricted28)
9930
10699
  }).meta({ id: "EntitlementCheckResult", "x-revturbine-schema-persistence": Transient27, "x-revturbine-schema-exposure": External13 });
@@ -10750,6 +11519,19 @@ var CATALOG = {
10750
11519
  message: "This limit rule sets no enforcement \u2014 at the cap it blocks by default. Set enforcement explicitly (hard_block, block_with_upsell, degrade, allow_overage) if that isn't the intent.",
10751
11520
  specRef: "config-validation.md \xA75.8 (promoted \u2014 plan 179 Q-6)"
10752
11521
  },
11522
+ // handle uniqueness (BL-0325; spec §5.1 VAL-UNI-01). A handle duplicates
11523
+ // another object's handle within the same handle-bearing collection — the
11524
+ // structural counterpart to the DB's partial unique indexes (migration
11525
+ // 0050) and Compile+Activate's own handle collapse. `error_draft`: per §2
11526
+ // this is a malformed/structurally-invalid value ("a duplicate handle"),
11527
+ // so it must never enter the draft at all, on any surface — including an
11528
+ // external config at ingestion.
11529
+ "VAL-UNI-01": {
11530
+ id: "VAL-UNI-01",
11531
+ severity: "error_draft",
11532
+ message: "This handle is already in use.",
11533
+ specRef: "config-validation.md \xA75.1 (VAL-UNI-01)"
11534
+ },
10753
11535
  // segment → experiment reference (plan 183). Handle, not id.
10754
11536
  "VAL-SEG-01": {
10755
11537
  id: "VAL-SEG-01",
@@ -10757,6 +11539,15 @@ var CATALOG = {
10757
11539
  message: "This segment enrols users into an experiment that does not exist.",
10758
11540
  specRef: "config-validation.md \xA75.3 (plan 183)"
10759
11541
  },
11542
+ // reserved built-in segment handle prefix (plan 279 TASK-2). Built-in
11543
+ // `rt.<dimension>.<value>` definitions are generated at export; an authored
11544
+ // one collides with or impersonates a built-in.
11545
+ "VAL-SEG-02": {
11546
+ id: "VAL-SEG-02",
11547
+ severity: "error_launch",
11548
+ message: "This segment's handle starts with 'rt.', which is reserved for built-in segments.",
11549
+ specRef: "config-validation.md \xA75.4; targeting-studio-ui.md \xA74.1 (plan 279)"
11550
+ },
10760
11551
  // experiment metric → Semantic Catalog reference (plan 199). The identifier
10761
11552
  // is structurally valid, but the active catalog does not declare it.
10762
11553
  "VAL-EXP-01": {
@@ -10799,11 +11590,81 @@ var CATALOG = {
10799
11590
  // both means every user on every plan — legal and occasionally intended,
10800
11591
  // but indistinguishable from an unfinished rule after authoring. Reverse
10801
11592
  // trial rules can't hit this (premium_plan_id is required).
11593
+ // ── Dangling handle references (BL-0124) ─────────────────────────────────
11594
+ // Four references nothing checked: each fails silently at runtime (the
11595
+ // trigger never arms, the trial grants nothing, the rule targets nobody, the
11596
+ // chip lowers to a literal no user carries), so a Playbook with a typo
11597
+ // validated clean and shipped dead config. `error_launch` per spec §2 — "a
11598
+ // reference whose target is created in a later step" is legitimate mid-draft
11599
+ // and must never reach launch.
11600
+ "VAL-PLC-08": {
11601
+ id: "VAL-PLC-08",
11602
+ severity: "error_launch",
11603
+ message: "This placement triggers on an entitlement that does not exist.",
11604
+ specRef: "config-validation.md \xA75.2 (BL-0124)"
11605
+ },
11606
+ "VAL-PLC-09": {
11607
+ id: "VAL-PLC-09",
11608
+ severity: "error_launch",
11609
+ message: "This payload targets a segment chip that matches no segment or plan.",
11610
+ specRef: "config-validation.md \xA75.2 (BL-0124)"
11611
+ },
11612
+ "VAL-PLN-08": {
11613
+ id: "VAL-PLN-08",
11614
+ severity: "error_launch",
11615
+ message: "This entitlement rule targets a plan, add-on, or variation that does not exist.",
11616
+ specRef: "config-validation.md \xA75.3 (BL-0124)"
11617
+ },
11618
+ "VAL-TRL-05": {
11619
+ id: "VAL-TRL-05",
11620
+ severity: "error_launch",
11621
+ message: "This trial rule references a plan that does not exist.",
11622
+ specRef: "config-validation.md \xA75.5 (BL-0124)"
11623
+ },
10802
11624
  "VAL-TRL-04": {
10803
11625
  id: "VAL-TRL-04",
10804
11626
  severity: "warning",
10805
11627
  message: "Trial rule '{rule}' targets every user on every plan (no plan, no segment). Confirm the widest possible audience is intended.",
10806
11628
  specRef: "config-validation.md \xA75.8 (promoted \u2014 plan 174 TASK-7)"
11629
+ },
11630
+ // entitlement rule → entitlement (BL-0164): same dangling-reference family as
11631
+ // the four above, just the one reference §5.9's original sweep missed.
11632
+ "VAL-ENT-01": {
11633
+ id: "VAL-ENT-01",
11634
+ severity: "error_launch",
11635
+ message: "This entitlement rule links an entitlement that does not exist.",
11636
+ specRef: "config-validation.md \xA75.3 (BL-0164)"
11637
+ },
11638
+ // placement / entitlement rule → objective (BL-0176, ruling D-15): the
11639
+ // optional `objective` field references an `objectives[]` entity by handle.
11640
+ // Same §5.9 dangling-reference family and severity.
11641
+ "VAL-OBJ-01": {
11642
+ id: "VAL-OBJ-01",
11643
+ severity: "error_launch",
11644
+ message: "This placement or entitlement rule names an objective that does not exist.",
11645
+ specRef: "config-validation.md \xA75.9 (BL-0176)"
11646
+ },
11647
+ // BL-0168: VAL-PLC-07's `plansKnown` precondition (every plan row carries a
11648
+ // non-empty `unique_handle`) fails silently for a DB-shape plan row that
11649
+ // spells the identifier `handle` — the whole check goes dark for the
11650
+ // Playbook with nothing recording that it did not run. `warning`, never
11651
+ // blocking: it is a meta-finding about check coverage, not a config defect.
11652
+ "VAL-PLC-07-SKIPPED": {
11653
+ id: "VAL-PLC-07-SKIPPED",
11654
+ severity: "warning",
11655
+ message: "VAL-PLC-07 ('targets no configured plan') could not run: the plan catalog carries a row with no usable unique_handle.",
11656
+ specRef: "config-validation.md \xA75.2 (BL-0168)"
11657
+ },
11658
+ // BL-0168: the payload→placement parent lookup (`placementPayloads`) matches
11659
+ // a standalone `placement_payloads` row on `row.id === payload.placement_id`
11660
+ // only. A miss leaves `category` undefined, `categoryBucket` falls through to
11661
+ // 99, and VAL-PLC-06's Access-Gate/Fixed cap check silently never runs for
11662
+ // that payload. `warning`, never blocking, same rationale as above.
11663
+ "VAL-PLC-06-SKIPPED": {
11664
+ id: "VAL-PLC-06-SKIPPED",
11665
+ severity: "warning",
11666
+ message: "VAL-PLC-06 (ignored cap/cooldown settings) could not run: this payload's placement_id matches no configured placement.",
11667
+ specRef: "config-validation.md \xA75.2 (BL-0168)"
10807
11668
  }
10808
11669
  };
10809
11670
  function getCatalogEntry(id) {
@@ -10823,6 +11684,168 @@ function categoryBucket(category) {
10823
11684
  if (normalized.includes("retention") || normalized.includes("winback") || normalized.includes("churn")) return 4;
10824
11685
  return 99;
10825
11686
  }
11687
+ var HANDLE_PATTERN2 = /^[a-z0-9._]{1,100}$/;
11688
+ var BUILTIN_SEGMENT_HANDLE_PREFIX = "rt.";
11689
+ var BUILTIN_TRAIT_KEY_PREFIX = "rt_";
11690
+ var MAX_HANDLE_LENGTH = 100;
11691
+ function dimension(key2, name, legacyAbbr, values, opts = {}) {
11692
+ const legacyPrefix = `seg:${legacyAbbr}:`;
11693
+ return {
11694
+ key: key2,
11695
+ name,
11696
+ traitKey: `${BUILTIN_TRAIT_KEY_PREFIX}${key2}`,
11697
+ dimensionId: `${BUILTIN_SEGMENT_HANDLE_PREFIX}${key2}`,
11698
+ vocabulary: opts.vocabulary ?? "closed",
11699
+ values: values.map(([value, valueName, legacyValue]) => ({
11700
+ value,
11701
+ name: valueName,
11702
+ legacyPickerId: `${legacyPrefix}${legacyValue ?? value}`
11703
+ })),
11704
+ ...opts.fallback !== void 0 ? { fallback: opts.fallback } : {},
11705
+ legacyPrefix
11706
+ };
11707
+ }
11708
+ var BUILTIN_DIMENSIONS = [
11709
+ dimension("registration_state", "Registration State", "reg", [
11710
+ ["unregistered", "Unregistered"],
11711
+ ["registered", "Registered"]
11712
+ ]),
11713
+ dimension(
11714
+ "activity_level",
11715
+ "Activity State",
11716
+ "act",
11717
+ [
11718
+ ["new", "New"],
11719
+ ["high", "High Activity"],
11720
+ ["medium", "Medium Activity"],
11721
+ ["low", "Low Activity"],
11722
+ ["inactive", "Inactive"]
11723
+ ],
11724
+ { fallback: "new" }
11725
+ ),
11726
+ dimension(
11727
+ "subscription_state",
11728
+ "Subscription State",
11729
+ "sub",
11730
+ [
11731
+ ["none", "None"],
11732
+ ["trial", "Trial"],
11733
+ ["paid", "Paid"],
11734
+ ["cancelled", "Cancelled"]
11735
+ ],
11736
+ { fallback: "none" }
11737
+ ),
11738
+ dimension(
11739
+ "trial_type",
11740
+ "Trial Type",
11741
+ "trial",
11742
+ [
11743
+ ["none", "Not in trial"],
11744
+ ["free_trial", "Free trial", "free"],
11745
+ ["reverse_trial", "Reverse trial", "reverse"]
11746
+ ],
11747
+ { fallback: "none" }
11748
+ ),
11749
+ dimension("seat_type", "Seat Type", "seat", [], { vocabulary: "seat_type" }),
11750
+ dimension("buyer_role", "Buyer Role", "role", [
11751
+ ["buyer", "Buyer"],
11752
+ ["non_buyer", "Non-buyer"]
11753
+ ]),
11754
+ dimension(
11755
+ "email_type",
11756
+ "Email Type",
11757
+ "email",
11758
+ [
11759
+ ["business", "Business email"],
11760
+ ["personal", "Personal email"],
11761
+ ["unknown", "Unknown"]
11762
+ ],
11763
+ { fallback: "unknown" }
11764
+ ),
11765
+ dimension(
11766
+ "billing_health",
11767
+ "Billing Health",
11768
+ "bill",
11769
+ [
11770
+ ["no_billing", "No billing", "none"],
11771
+ ["good_standing", "Good standing", "good"],
11772
+ ["trial_payment_method_attached", "Trial \u2014 payment method attached", "trial_pm"],
11773
+ ["payment_method_missing", "Payment method missing", "pm_missing"],
11774
+ ["payment_failed", "Payment failed", "failed"],
11775
+ ["payment_overdue", "Payment overdue", "overdue"],
11776
+ ["cancelled", "Cancelled"]
11777
+ ],
11778
+ { fallback: "no_billing" }
11779
+ ),
11780
+ dimension("region", "Country / Region", "geo", [
11781
+ ["us_canada", "US / Canada"],
11782
+ ["europe", "Europe"],
11783
+ ["rest_of_world", "Rest of World", "row"]
11784
+ ]),
11785
+ dimension(
11786
+ "device_type",
11787
+ "Device Type",
11788
+ "dev",
11789
+ [
11790
+ ["desktop", "Desktop"],
11791
+ ["mobile", "Mobile"],
11792
+ ["tablet", "Tablet"],
11793
+ ["unknown", "Unknown"]
11794
+ ],
11795
+ { fallback: "unknown" }
11796
+ )
11797
+ ];
11798
+ var BY_KEY = new Map(
11799
+ BUILTIN_DIMENSIONS.map((d) => [d.key, d])
11800
+ );
11801
+ function isBuiltinDimensionKey(key2) {
11802
+ return BY_KEY.has(key2);
11803
+ }
11804
+ function getBuiltinDimension(key2) {
11805
+ const found = BY_KEY.get(key2);
11806
+ if (!found) throw new Error(`Unknown built-in dimension '${key2}'`);
11807
+ return found;
11808
+ }
11809
+ function builtinSegmentHandle(key2, value) {
11810
+ return `${BUILTIN_SEGMENT_HANDLE_PREFIX}${key2}.${value}`;
11811
+ }
11812
+ function isReservedSegmentHandle(handle) {
11813
+ return handle.startsWith(BUILTIN_SEGMENT_HANDLE_PREFIX);
11814
+ }
11815
+ function isBuiltinDimensionValue(key2, value) {
11816
+ const dim = getBuiltinDimension(key2);
11817
+ if (dim.vocabulary === "closed") return dim.values.some((v) => v.value === value);
11818
+ return HANDLE_PATTERN2.test(value) && builtinSegmentHandle(key2, value).length <= MAX_HANDLE_LENGTH;
11819
+ }
11820
+ function parseBuiltinSegmentHandle(handle) {
11821
+ if (!isReservedSegmentHandle(handle)) return void 0;
11822
+ const rest = handle.slice(BUILTIN_SEGMENT_HANDLE_PREFIX.length);
11823
+ const dot = rest.indexOf(".");
11824
+ if (dot <= 0) return void 0;
11825
+ const key2 = rest.slice(0, dot);
11826
+ const value = rest.slice(dot + 1);
11827
+ if (!isBuiltinDimensionKey(key2) || !isBuiltinDimensionValue(key2, value)) return void 0;
11828
+ return { key: key2, value };
11829
+ }
11830
+ function isGeneratedBuiltinSegment(segment) {
11831
+ if (typeof segment.handle !== "string") return false;
11832
+ const parsed = parseBuiltinSegmentHandle(segment.handle);
11833
+ if (!parsed) return false;
11834
+ const dim = getBuiltinDimension(parsed.key);
11835
+ if (segment.dimension_id !== dim.dimensionId) return false;
11836
+ if (segment.experiment_handle !== void 0 && segment.experiment_handle !== null) return false;
11837
+ const predicates = segment.predicates;
11838
+ if (!Array.isArray(predicates) || predicates.length !== 1) return false;
11839
+ const [p] = predicates;
11840
+ if (!p || typeof p !== "object") return false;
11841
+ const { field, operator, value } = p;
11842
+ return field === dim.traitKey && operator === "eq" && value === parsed.value;
11843
+ }
11844
+ var LEGACY_BY_ID = new Map(
11845
+ BUILTIN_DIMENSIONS.flatMap(
11846
+ (d) => d.values.map((v) => [v.legacyPickerId, builtinSegmentHandle(d.key, v.value)])
11847
+ )
11848
+ );
10826
11849
  function runSemanticRules(graph, opts = {}) {
10827
11850
  return [
10828
11851
  ...checkRuleOverlaps(graph),
@@ -10832,28 +11855,321 @@ function runSemanticRules(graph, opts = {}) {
10832
11855
  ...checkPlacementAuthoring(graph),
10833
11856
  ...checkTrialRuleWidestAudience(graph),
10834
11857
  ...checkSegmentExperimentRefs(graph),
10835
- ...checkExperimentMetricRefs(graph, opts.knownMetricIds)
11858
+ ...checkReservedSegmentHandles(graph),
11859
+ ...checkHandleUniqueness(graph),
11860
+ ...checkExperimentMetricRefs(graph, opts.knownMetricIds),
11861
+ ...checkDanglingReferences(graph)
11862
+ ];
11863
+ }
11864
+ function referenceNamespace(graph, table2, handleField) {
11865
+ const rows = graph[table2];
11866
+ if (!rows) return void 0;
11867
+ const known = /* @__PURE__ */ new Set();
11868
+ for (const row of rows) {
11869
+ const handle = row[handleField];
11870
+ if (typeof handle !== "string" || !handle) return void 0;
11871
+ known.add(handle);
11872
+ }
11873
+ return known;
11874
+ }
11875
+ function knownHandlesHint(known, label) {
11876
+ if (known.size === 0) return "";
11877
+ const sorted = [...known].sort();
11878
+ const shown = sorted.slice(0, 8);
11879
+ const rest = sorted.length > shown.length ? `, \u2026 (${sorted.length} total)` : "";
11880
+ return ` Declared ${label}: ${shown.map((h) => `'${h}'`).join(", ")}${rest}.`;
11881
+ }
11882
+ function firstStringField(row, fields) {
11883
+ for (const field of fields) {
11884
+ const value = row[field];
11885
+ if (typeof value === "string" && value.length > 0) return { field, value };
11886
+ }
11887
+ return void 0;
11888
+ }
11889
+ function checkDanglingReferences(graph) {
11890
+ return [
11891
+ ...checkTriggerEntitlementRefs(graph),
11892
+ ...checkTrialPlanRefs(graph),
11893
+ ...checkEntitlementRuleTargetRefs(graph),
11894
+ ...checkEntitlementRuleEntitlementRefs(graph),
11895
+ ...checkPayloadSegmentChipRefs(graph),
11896
+ ...checkObjectiveRefs(graph)
10836
11897
  ];
10837
11898
  }
11899
+ var OBJECTIVE_REFERRERS = [
11900
+ { table: "placements", objectType: "placement", label: "Placement", studio: "placements" },
11901
+ { table: "entitlement_rules", objectType: "entitlement_rule", label: "Entitlement rule", studio: "plans-entitlements" }
11902
+ ];
11903
+ function checkObjectiveRefs(graph) {
11904
+ const known = referenceNamespace(graph, "objectives", "handle");
11905
+ if (!known) return [];
11906
+ const findings = [];
11907
+ for (const referrer of OBJECTIVE_REFERRERS) {
11908
+ for (const [index, row] of (graph[referrer.table] ?? []).entries()) {
11909
+ const value = row.objective;
11910
+ if (typeof value !== "string" || value.length === 0 || known.has(value)) continue;
11911
+ const id = String(row.handle ?? row.id ?? "");
11912
+ findings.push(
11913
+ finding(
11914
+ "VAL-OBJ-01",
11915
+ {
11916
+ object_type: referrer.objectType,
11917
+ object_id: id,
11918
+ field: "objective",
11919
+ path: [referrer.table, index, "objective"],
11920
+ studio: referrer.studio
11921
+ },
11922
+ {
11923
+ message: `${referrer.label} '${id || "unnamed"}' names objective '${value}', which no objective declares.`,
11924
+ detail: "An objective reference must be an objective's handle, not its config id. A reference that matches nothing drops this object out of the objective slice, so its results report under no objective." + knownHandlesHint(known, "objective handles")
11925
+ }
11926
+ )
11927
+ );
11928
+ }
11929
+ }
11930
+ return findings;
11931
+ }
11932
+ function checkTriggerEntitlementRefs(graph) {
11933
+ const known = referenceNamespace(graph, "entitlements", "unique_handle");
11934
+ if (!known) return [];
11935
+ const findings = [];
11936
+ for (const [index, placement] of (graph.placements ?? []).entries()) {
11937
+ if (!isRecord2(placement.trigger)) continue;
11938
+ const ref = firstStringField(placement.trigger, ["entitlement_handle", "entitlement_id"]);
11939
+ if (!ref || known.has(ref.value)) continue;
11940
+ const id = String(placement.handle ?? placement.id ?? "");
11941
+ findings.push(
11942
+ finding(
11943
+ "VAL-PLC-08",
11944
+ {
11945
+ object_type: "placement",
11946
+ object_id: id,
11947
+ field: `trigger.${ref.field}`,
11948
+ path: ["placements", index, "trigger", ref.field],
11949
+ studio: "placements"
11950
+ },
11951
+ {
11952
+ message: `Placement '${id}' triggers on entitlement '${ref.value}', which no entitlement declares.`,
11953
+ detail: "A trigger's entitlement reference must be an entitlement's unique_handle, not its config id. A reference that matches nothing never arms the trigger, so the placement looks configured and can never show." + knownHandlesHint(known, "entitlement handles")
11954
+ }
11955
+ )
11956
+ );
11957
+ }
11958
+ return findings;
11959
+ }
11960
+ var TRIAL_PLAN_FIELDS = ["plan_id", "convert_to_plan_id", "fallback_plan_id", "premium_plan_id"];
11961
+ function checkTrialPlanRefs(graph) {
11962
+ const known = referenceNamespace(graph, "plans", "unique_handle");
11963
+ if (!known) return [];
11964
+ const findings = [];
11965
+ for (const table2 of ["free_trial_rules", "reverse_trial_rules"]) {
11966
+ const objectType = table2 === "free_trial_rules" ? "free_trial_rule" : "reverse_trial_rule";
11967
+ for (const [index, rule] of (graph[table2] ?? []).entries()) {
11968
+ const id = String(rule.handle ?? rule.id ?? "");
11969
+ for (const field of TRIAL_PLAN_FIELDS) {
11970
+ const value = rule[field];
11971
+ if (typeof value !== "string" || value.length === 0 || known.has(value)) continue;
11972
+ findings.push(
11973
+ finding(
11974
+ "VAL-TRL-05",
11975
+ {
11976
+ object_type: objectType,
11977
+ object_id: id,
11978
+ field,
11979
+ path: [table2, index, field],
11980
+ studio: "trials"
11981
+ },
11982
+ {
11983
+ message: `Trial rule '${id || "unnamed"}' references plan '${value}' in ${field}, which no plan declares.`,
11984
+ detail: "Trial plan references must be a plan's unique_handle, not its config id. A reference that matches nothing makes the rule grant or convert nothing, and the trial silently never runs." + knownHandlesHint(known, "plan handles")
11985
+ }
11986
+ )
11987
+ );
11988
+ }
11989
+ }
11990
+ }
11991
+ return findings;
11992
+ }
11993
+ var TARGET_KIND_TABLES = {
11994
+ plan: { table: "plans", handleField: "unique_handle", label: "plan" },
11995
+ addon: { table: "addons", handleField: "unique_handle", label: "add-on" },
11996
+ plan_variation: { table: "plan_variations", handleField: "handle", label: "plan variation" },
11997
+ addon_variation: { table: "addon_variations", handleField: "handle", label: "add-on variation" }
11998
+ };
11999
+ function checkEntitlementRuleTargetRefs(graph) {
12000
+ const namespaces = /* @__PURE__ */ new Map();
12001
+ const namespaceFor = (kind) => {
12002
+ const spec = TARGET_KIND_TABLES[kind];
12003
+ if (!spec) return void 0;
12004
+ if (!namespaces.has(kind)) namespaces.set(kind, referenceNamespace(graph, spec.table, spec.handleField));
12005
+ return namespaces.get(kind);
12006
+ };
12007
+ const findings = [];
12008
+ for (const [index, rule] of (graph.entitlement_rules ?? []).entries()) {
12009
+ const id = String(rule.handle ?? rule.id ?? "");
12010
+ const refs = [];
12011
+ if (Array.isArray(rule.targets)) {
12012
+ for (const [targetIndex, target] of rule.targets.entries()) {
12013
+ if (!isRecord2(target) || typeof target.id !== "string" || target.id.length === 0) continue;
12014
+ const kind = typeof target.kind === "string" ? target.kind : "plan";
12015
+ refs.push({
12016
+ kind,
12017
+ value: target.id,
12018
+ field: `targets.${targetIndex}.id`,
12019
+ path: ["entitlement_rules", index, "targets", targetIndex, "id"]
12020
+ });
12021
+ }
12022
+ } else if (Array.isArray(rule.plan_ids)) {
12023
+ for (const [planIndex, planRef] of rule.plan_ids.entries()) {
12024
+ if (typeof planRef !== "string" || planRef.length === 0) continue;
12025
+ refs.push({
12026
+ kind: "plan",
12027
+ value: planRef,
12028
+ field: `plan_ids.${planIndex}`,
12029
+ path: ["entitlement_rules", index, "plan_ids", planIndex]
12030
+ });
12031
+ }
12032
+ } else if (typeof rule.plan_id === "string" && rule.plan_id.length > 0) {
12033
+ refs.push({
12034
+ kind: "plan",
12035
+ value: rule.plan_id,
12036
+ field: "plan_id",
12037
+ path: ["entitlement_rules", index, "plan_id"]
12038
+ });
12039
+ }
12040
+ for (const ref of refs) {
12041
+ const known = namespaceFor(ref.kind);
12042
+ if (!known || known.has(ref.value)) continue;
12043
+ const label = TARGET_KIND_TABLES[ref.kind]?.label ?? ref.kind;
12044
+ findings.push(
12045
+ finding(
12046
+ "VAL-PLN-08",
12047
+ {
12048
+ object_type: "entitlement_rule",
12049
+ object_id: id,
12050
+ field: ref.field,
12051
+ path: ref.path,
12052
+ studio: "plans-entitlements"
12053
+ },
12054
+ {
12055
+ message: `Entitlement rule '${id || "unnamed"}' targets ${label} '${ref.value}', which no ${label} declares.`,
12056
+ detail: "Rule targets must be the target's unique_handle, not its config id. A target that matches nothing makes the rule apply to no one, so the entitlement it grants is silently absent for every user." + knownHandlesHint(known, `${label} handles`)
12057
+ }
12058
+ )
12059
+ );
12060
+ }
12061
+ }
12062
+ return findings;
12063
+ }
12064
+ function checkEntitlementRuleEntitlementRefs(graph) {
12065
+ const known = referenceNamespace(graph, "entitlements", "unique_handle");
12066
+ if (!known) return [];
12067
+ const findings = [];
12068
+ for (const [index, rule] of (graph.entitlement_rules ?? []).entries()) {
12069
+ const value = rule.entitlement_id;
12070
+ if (typeof value !== "string" || value.length === 0 || known.has(value)) continue;
12071
+ const id = String(rule.handle ?? rule.id ?? "");
12072
+ findings.push(
12073
+ finding(
12074
+ "VAL-ENT-01",
12075
+ {
12076
+ object_type: "entitlement_rule",
12077
+ object_id: id,
12078
+ field: "entitlement_id",
12079
+ path: ["entitlement_rules", index, "entitlement_id"],
12080
+ studio: "plans-entitlements"
12081
+ },
12082
+ {
12083
+ message: `Entitlement rule '${id || "unnamed"}' links entitlement '${value}', which no entitlement declares.`,
12084
+ detail: "A rule's entitlement reference must be an entitlement's unique_handle, not its config id. A reference that matches nothing means the entitlement it grants or limits is silently absent for every user." + knownHandlesHint(known, "entitlement handles")
12085
+ }
12086
+ )
12087
+ );
12088
+ }
12089
+ return findings;
12090
+ }
12091
+ function payloadSegmentChips(payload, path3) {
12092
+ const target = isRecord2(payload.target) ? payload.target : void 0;
12093
+ if (target && Array.isArray(target.segment_chips)) {
12094
+ return { fields: ["target", "segment_chips"], path: [...path3, "target", "segment_chips"], chips: target.segment_chips };
12095
+ }
12096
+ if (Array.isArray(payload.target_segment_chips)) {
12097
+ return { fields: ["target_segment_chips"], path: [...path3, "target_segment_chips"], chips: payload.target_segment_chips };
12098
+ }
12099
+ return void 0;
12100
+ }
12101
+ function checkPayloadSegmentChipRefs(graph) {
12102
+ const segments = referenceNamespace(graph, "segments", "handle");
12103
+ const plans = referenceNamespace(graph, "plans", "unique_handle");
12104
+ if (!segments || !plans) return [];
12105
+ const findings = [];
12106
+ for (const { payload, path: path3 } of placementPayloads(graph)) {
12107
+ const chips = payloadSegmentChips(payload, path3);
12108
+ if (!chips) continue;
12109
+ const id = String(payload.payload_id ?? payload.id ?? "");
12110
+ for (const [chipIndex, raw] of chips.chips.entries()) {
12111
+ if (typeof raw !== "string") continue;
12112
+ const value = raw.trim();
12113
+ if (!value || segments.has(value) || plans.has(value)) continue;
12114
+ findings.push(
12115
+ finding(
12116
+ "VAL-PLC-09",
12117
+ {
12118
+ object_type: "placement_payload",
12119
+ object_id: id,
12120
+ field: `${chips.fields.join(".")}.${chipIndex}`,
12121
+ path: [...chips.path, chipIndex],
12122
+ studio: "placements"
12123
+ },
12124
+ {
12125
+ message: `Payload '${id}' targets segment chip '${value}', which matches no segment or plan.`,
12126
+ detail: "A chip resolves against segment handles first, then plan handles \u2014 never a config id or a display name. An unresolved chip compiles to a literal no user carries, so the payload can never be shown." + knownHandlesHint(segments, "segment handles")
12127
+ }
12128
+ )
12129
+ );
12130
+ }
12131
+ }
12132
+ return findings;
12133
+ }
10838
12134
  function* placementPayloads(graph) {
10839
12135
  const placements = graph.placements ?? [];
10840
12136
  for (const [index, placement] of placements.entries()) {
10841
12137
  if (!Array.isArray(placement.payloads)) continue;
10842
12138
  for (const [payloadIndex, payload] of placement.payloads.entries()) {
10843
- if (isRecord2(payload)) yield { payload, category: placement.category, path: ["placements", index, "payloads", payloadIndex] };
12139
+ if (isRecord2(payload)) yield { payload, category: placement.category, path: ["placements", index, "payloads", payloadIndex], parentFound: true };
10844
12140
  }
10845
12141
  }
10846
12142
  for (const [index, payload] of (graph.placement_payloads ?? []).entries()) {
10847
12143
  const placement = placements.find((row) => typeof row.id === "string" && row.id === payload.placement_id);
10848
- yield { payload, category: placement?.category, path: ["placement_payloads", index] };
12144
+ yield { payload, category: placement?.category, path: ["placement_payloads", index], parentFound: placement !== void 0 };
10849
12145
  }
10850
12146
  }
10851
12147
  function checkPlacementAuthoring(graph) {
10852
12148
  const findings = [];
10853
- const plansKnown = Array.isArray(graph.plans) && graph.plans.every((plan) => typeof plan.unique_handle === "string" && plan.unique_handle.length > 0);
10854
- const planHandles = new Set((graph.plans ?? []).map((plan) => plan.unique_handle));
10855
- for (const { payload, category, path: path3 } of placementPayloads(graph)) {
12149
+ const plans = graph.plans;
12150
+ const plansKnown = Array.isArray(plans) && plans.every((plan) => typeof plan.unique_handle === "string" && plan.unique_handle.length > 0);
12151
+ const planHandles = new Set((plans ?? []).map((plan) => plan.unique_handle));
12152
+ if (Array.isArray(plans) && plans.length > 0 && !plansKnown) {
12153
+ findings.push(finding("VAL-PLC-07-SKIPPED", {
12154
+ object_type: "plan",
12155
+ studio: "placements"
12156
+ }, {
12157
+ detail: "Every plan row needs a non-empty unique_handle for VAL-PLC-07 to run against this Playbook. A DB-shape row that spells the identifier `handle` does not satisfy it."
12158
+ }));
12159
+ }
12160
+ for (const { payload, category, path: path3, parentFound } of placementPayloads(graph)) {
10856
12161
  const id = String(payload.payload_id ?? payload.id ?? "");
12162
+ if (!parentFound) {
12163
+ findings.push(finding("VAL-PLC-06-SKIPPED", {
12164
+ object_type: "placement_payload",
12165
+ object_id: id,
12166
+ field: "placement_id",
12167
+ studio: "placements"
12168
+ }, {
12169
+ message: `Payload '${id}' names a placement_id that matches no configured placement, so VAL-PLC-06's cap/cooldown check could not run for it.`,
12170
+ detail: "Fix placement_id (or add the placement it should reference) to restore the cap check for Access Gate and Fixed placements."
12171
+ }));
12172
+ }
10857
12173
  const bucket = typeof category === "string" ? categoryBucket(category) : 99;
10858
12174
  if (bucket === 0 || bucket === 1) {
10859
12175
  const caps = isRecord2(payload.caps) ? payload.caps : {};
@@ -10996,6 +12312,90 @@ function checkSegmentExperimentRefs(graph) {
10996
12312
  }
10997
12313
  return findings;
10998
12314
  }
12315
+ function checkReservedSegmentHandles(graph) {
12316
+ const findings = [];
12317
+ for (const table2 of ["segments", "segment_values"]) {
12318
+ for (const [index, segment] of (graph[table2] ?? []).entries()) {
12319
+ const handle = segment.handle;
12320
+ if (typeof handle !== "string" || !isReservedSegmentHandle(handle)) continue;
12321
+ if (table2 === "segments" && isGeneratedBuiltinSegment(segment)) continue;
12322
+ findings.push(
12323
+ finding(
12324
+ "VAL-SEG-02",
12325
+ {
12326
+ object_type: "segment",
12327
+ object_id: handle,
12328
+ field: "handle",
12329
+ path: [table2, index, "handle"],
12330
+ studio: "targeting"
12331
+ },
12332
+ {
12333
+ message: `Segment '${handle}' uses the reserved '${BUILTIN_SEGMENT_HANDLE_PREFIX}' handle prefix.`,
12334
+ detail: `Handles starting with '${BUILTIN_SEGMENT_HANDLE_PREFIX}' are reserved for RevTurbine's built-in segments, which are generated at export. Rename the segment; to target a built-in value, select it from the built-in dimension instead.`
12335
+ }
12336
+ )
12337
+ );
12338
+ }
12339
+ }
12340
+ return findings;
12341
+ }
12342
+ var HANDLE_UNIQUENESS_COLLECTIONS = [
12343
+ { table: "plans", handleFields: ["unique_handle", "handle"], objectType: "plan", studio: "plans-entitlements" },
12344
+ { table: "addons", handleFields: ["unique_handle", "handle"], objectType: "addon", studio: "plans-entitlements" },
12345
+ {
12346
+ table: "entitlements",
12347
+ handleFields: ["unique_handle", "handle"],
12348
+ objectType: "entitlement",
12349
+ studio: "plans-entitlements"
12350
+ },
12351
+ {
12352
+ table: "segments",
12353
+ handleFields: ["handle"],
12354
+ objectType: "segment",
12355
+ studio: "targeting",
12356
+ exempt: (row) => isGeneratedBuiltinSegment(row)
12357
+ },
12358
+ { table: "segment_values", handleFields: ["handle"], objectType: "segment", studio: "targeting" },
12359
+ { table: "seat_types", handleFields: ["handle"], objectType: "seat_type", studio: "plans-entitlements" }
12360
+ ];
12361
+ function checkHandleUniqueness(graph) {
12362
+ const findings = [];
12363
+ for (const { table: table2, handleFields, objectType, studio, exempt } of HANDLE_UNIQUENESS_COLLECTIONS) {
12364
+ const rows = graph[table2];
12365
+ if (!rows?.length) continue;
12366
+ const seen = /* @__PURE__ */ new Set();
12367
+ for (const [index, row] of rows.entries()) {
12368
+ if (row.is_current === false) continue;
12369
+ if (exempt?.(row)) continue;
12370
+ const field = firstStringField(row, handleFields);
12371
+ if (!field) continue;
12372
+ const tenant = String(row.tenant_id ?? "");
12373
+ const environment = String(row.environment_id ?? "");
12374
+ const key2 = `${tenant}::${environment}::${field.value}`;
12375
+ if (!seen.has(key2)) {
12376
+ seen.add(key2);
12377
+ continue;
12378
+ }
12379
+ const id = String(row.id ?? field.value);
12380
+ findings.push(
12381
+ finding(
12382
+ "VAL-UNI-01",
12383
+ {
12384
+ object_type: objectType,
12385
+ object_id: id,
12386
+ field: field.field,
12387
+ path: [table2, index, field.field],
12388
+ studio
12389
+ },
12390
+ {
12391
+ detail: `Handle '${field.value}' is used by more than one ${objectType} \u2014 handles must be unique within a ${objectType} collection.`
12392
+ }
12393
+ )
12394
+ );
12395
+ }
12396
+ }
12397
+ return findings;
12398
+ }
10999
12399
  function checkExperimentMetricRefs(graph, knownMetricIds) {
11000
12400
  if (!knownMetricIds) return [];
11001
12401
  const findings = [];
@@ -11244,7 +12644,6 @@ function evaluate(graph, opts = {}) {
11244
12644
  if (!opts.focus) return findings;
11245
12645
  return findings.map((f) => spotlights(f, opts.focus) ? { ...f, spotlight: true } : f);
11246
12646
  }
11247
- var HANDLE_PATTERN2 = /^[a-z0-9._]{1,100}$/;
11248
12647
  var SchemaPersistence2 = {
11249
12648
  Persisted: "persisted",
11250
12649
  Transient: "transient"
@@ -11506,6 +12905,8 @@ var CtaActionTypeSchema2 = z39.enum([
11506
12905
  "snooze",
11507
12906
  "custom"
11508
12907
  ]).meta({ id: "CtaActionType", "x-revturbine-schema-persistence": Transient30, "x-revturbine-schema-exposure": External14 });
12908
+ var ObjectiveField2 = HandleField2.optional();
12909
+ var NullableObjectiveField2 = z39.string().min(1).max(100).nullable().optional();
11509
12910
  var IdentityKind2 = {
11510
12911
  /** Author-given, human-meaningful handle (plans, entitlements, segments, …). */
11511
12912
  Named: "named",
@@ -11711,6 +13112,16 @@ var EntitlementRuleSchema2 = IdField2.merge(TimestampFields2).merge(TenantIdFiel
11711
13112
  // "match all users" (replaces the legacy 'all' sentinel).
11712
13113
  segment_ids: z62.array(z62.string()).default([]).meta(Unrestricted210),
11713
13114
  visibility: RuleVisibilitySchema2.default("public").meta(Unrestricted210),
13115
+ // Business objective this rule monetizes for — the analytics `objective`
13116
+ // slice, which resolves `rule_handle → objective` through a configuration
13117
+ // snapshot (BL-0065 / worksheet G4). A reference by handle to an
13118
+ // `objectives[]` entity (D-15; VAL-OBJ-01). Config-only, and deliberately
13119
+ // NOT part of the minted identity below: an objective relabel is a
13120
+ // behaviour-only edit that coalesces onto the same rule, not a new rule scope.
13121
+ // Nullable on the persisted entity so an editor can CLEAR the reference
13122
+ // (a PATCH merges, so omission keeps the old value); the portable
13123
+ // projection stays optional-only and omits it when unset.
13124
+ objective: NullableObjectiveField2.meta(Unrestricted210),
11714
13125
  // Usage-Limit "measured over" window, rule-level (plan #55). Rate Limit
11715
13126
  // keeps its entitlement-level `period_scope`; this is the per-rule one.
11716
13127
  period_scope: UsagePeriodScopeSchema2.optional().meta(Unrestricted210),
@@ -12129,6 +13540,39 @@ var TrialInstanceSchema2 = IdField2.merge(TimestampFields2).merge(TenantIdField2
12129
13540
  usage_limit_value: z72.number().int().min(1).optional().meta(Unrestricted33),
12130
13541
  converted_at: NullableDatetimeField2.meta(Unrestricted33),
12131
13542
  cancelled_at: NullableDatetimeField2.meta(Unrestricted33),
13543
+ /**
13544
+ * Stable identity of the trial EPISODE this row represents
13545
+ * (BL-0247 / plan 276 TASK-12). A customer can run more than one
13546
+ * trial, and a subscription can re-enter `trialing`, so the
13547
+ * (customer, subscription) pair does not name an occurrence. The
13548
+ * billing lane already discriminates an episode by the Stripe
13549
+ * subscription id plus its `trial_start`; this column persists
13550
+ * that same discriminator so a fact about one episode can be
13551
+ * written to exactly that episode's row.
13552
+ *
13553
+ * Nullable: rows created before the column existed, and rows
13554
+ * created by paths that have no Stripe evidence to mint an id
13555
+ * from, carry NULL. NULL means "this episode has no first-class
13556
+ * identity", never "episode zero" — and Postgres treats NULLs as
13557
+ * distinct, so the composite UNIQUE below does not collapse them.
13558
+ */
13559
+ trial_episode_id: z72.string().min(1).nullable().optional().meta(Unrestricted33),
13560
+ /**
13561
+ * The Stripe subscription this episode belongs to — the binding
13562
+ * that lets a Stripe fact resolve one episode without guessing
13563
+ * (BL-0245 carried this in `metadata.stripe_subscription_id`).
13564
+ * Nullable because a trial need not originate from Stripe at all.
13565
+ */
13566
+ stripe_subscription_id: z72.string().min(1).nullable().optional().meta(Unrestricted33),
13567
+ /**
13568
+ * The EVIDENCED end of the episode: the moment a provider fact
13569
+ * said the trial actually stopped. Distinct from `expires_at`,
13570
+ * which is the SCHEDULED end and must stay distinguishable
13571
+ * (plan 276 R-1 — no clock authority; an elapsed `expires_at`
13572
+ * with no fact behind it is not an end). Null until a fact
13573
+ * proves one.
13574
+ */
13575
+ actual_end_at: NullableDatetimeField2.meta(Unrestricted33),
12132
13576
  metadata: MetadataField2.meta(Unrestricted33)
12133
13577
  }).meta(
12134
13578
  { id: "TrialInstance", "x-revturbine-schema-persistence": Persisted32, "x-revturbine-schema-exposure": Internal32 }
@@ -12331,7 +13775,12 @@ var trialPaths2 = {
12331
13775
  summary: "List trial instances",
12332
13776
  tags: ["trials"],
12333
13777
  responses: { "200": { description: "Trial instance list", content: { "application/json": { schema: ListEnvelope2(TrialInstanceSchema2) } } } },
12334
- "x-revturbine-operation": { exposure: "internal", resource: "trial-instances", persistence: { table: "trialInstances", mode: "list" } }
13778
+ // One row per trial EPISODE (BL-0247 / plan 276 TASK-12). This is the
13779
+ // ON CONFLICT target the Stripe binding writer upserts against, so a
13780
+ // re-delivered `customer.subscription.*` event updates the episode it
13781
+ // already created instead of minting a duplicate. NULL episode ids are
13782
+ // distinct in Postgres, so pre-existing and non-Stripe rows are unaffected.
13783
+ "x-revturbine-operation": { exposure: "internal", resource: "trial-instances", persistence: { table: "trialInstances", mode: "list", uniqueBy: ["tenant_id", "customer_id", "trial_episode_id"] } }
12335
13784
  })
12336
13785
  },
12337
13786
  "/api/trials/instances/{instanceId}": {
@@ -12885,6 +14334,16 @@ var PersonalizationTokenSchema2 = IdField2.merge(TimestampFields2).merge(TenantI
12885
14334
  { id: "PersonalizationToken", "x-revturbine-schema-persistence": Persisted52, "x-revturbine-schema-exposure": Internal42, ...PENDING_PLAYBOOK_FACETS22, ...namedIdentity2() }
12886
14335
  );
12887
14336
  var PersonalizationTokenAnchorSchema2 = makeAnchor2("PersonalizationTokenAnchor");
14337
+ var ObjectiveSchema2 = IdField2.merge(TimestampFields2).merge(TenantIdField2).merge(AnchorFields2).merge(VersionFields2).extend({
14338
+ anchor_id: z92.string().min(1).meta({ ...Unrestricted52, readOnly: true }),
14339
+ handle: HandleField2.meta(Unrestricted52),
14340
+ name: NameField2.meta(Unrestricted52),
14341
+ description: DescriptionField2.meta(Unrestricted52),
14342
+ metadata: MetadataField2.meta(Unrestricted52)
14343
+ }).meta(
14344
+ { id: "Objective", "x-revturbine-schema-persistence": Persisted52, "x-revturbine-schema-exposure": Internal42, ...PLAYBOOK_AUTHORING_FACETS22, ...namedIdentity2() }
14345
+ );
14346
+ var ObjectiveAnchorSchema2 = makeAnchor2("ObjectiveAnchor");
12888
14347
  var OnboardingStateSchema2 = z92.enum(["not_started", "started", "details_submitted", "charges_enabled", "activated", "deauthorized"]).meta({ id: "OnboardingState", "x-revturbine-schema-persistence": Transient52, "x-revturbine-schema-exposure": Internal42 });
12889
14348
  var StripeIntegrationConfigSchema2 = IdField2.merge(TimestampFields2).merge(TenantIdField2).merge(AnchorFields2).merge(VersionFields2).extend({
12890
14349
  handle: HandleField2.meta({ ...Unrestricted52, readOnly: true }),
@@ -13202,7 +14661,9 @@ var RevTurbineConfigEntitlementRulesItemSchema2 = z92.object({
13202
14661
  }).shape,
13203
14662
  current_usage: z92.number().default(0).meta(Unrestricted52),
13204
14663
  /** How usage is partitioned across the identity hierarchy. */
13205
- allocation: UsageAllocationSchema2.optional().meta(Unrestricted52)
14664
+ allocation: UsageAllocationSchema2.optional().meta(Unrestricted52),
14665
+ /** Optional business objective, by `objectives[].handle` (VAL-OBJ-01). Config-only. */
14666
+ objective: ObjectiveField2.meta(Unrestricted52)
13206
14667
  }).meta(
13207
14668
  { id: "RevTurbineConfigEntitlementRulesItem", "x-revturbine-schema-persistence": Transient52, "x-revturbine-schema-exposure": External42, ...PLAYBOOK_SDK_FACETS42 }
13208
14669
  );
@@ -13298,6 +14759,13 @@ var RevTurbineConfigPersonalizationTokensItemSchema2 = z92.object({
13298
14759
  }).meta(
13299
14760
  { id: "RevTurbineConfigPersonalizationTokensItem", "x-revturbine-schema-persistence": Transient52, "x-revturbine-schema-exposure": External42, ...PLAYBOOK_SDK_FACETS42 }
13300
14761
  );
14762
+ var RevTurbineConfigObjectivesItemSchema2 = z92.object({
14763
+ handle: HandleField2.meta(Unrestricted52),
14764
+ name: NameField2.meta(Unrestricted52),
14765
+ description: DescriptionField2.meta(Unrestricted52)
14766
+ }).meta(
14767
+ { id: "RevTurbineConfigObjectivesItem", "x-revturbine-schema-persistence": Transient52, "x-revturbine-schema-exposure": External42, ...PLAYBOOK_AUTHORING_FACETS22 }
14768
+ );
13301
14769
  var MessageBlockContentSchema2 = z92.object({
13302
14770
  header: z92.string().optional().meta(Unrestricted52),
13303
14771
  body: z92.string().optional().meta(Unrestricted52),
@@ -13401,7 +14869,9 @@ var RevTurbineConfigPlacementItemSchema2 = z92.object({
13401
14869
  category: RevTurbineConfigPlacementCategorySchema2.meta(Unrestricted52),
13402
14870
  trigger: RevTurbineConfigPlacementTriggerSchema2.meta(Unrestricted52),
13403
14871
  payloads: z92.array(RevTurbineConfigStudioPayloadSchema2).meta(Unrestricted52),
13404
- order: z92.number().int().min(0).meta(Unrestricted52)
14872
+ order: z92.number().int().min(0).meta(Unrestricted52),
14873
+ /** Optional business objective, by `objectives[].handle` (VAL-OBJ-01). Config-only. */
14874
+ objective: ObjectiveField2.meta(Unrestricted52)
13405
14875
  }).meta(
13406
14876
  { id: "RevTurbineConfigPlacementItem", "x-revturbine-schema-persistence": Transient52, "x-revturbine-schema-exposure": External42, ...PLAYBOOK_SDK_FACETS42 }
13407
14877
  );
@@ -13495,6 +14965,14 @@ var PlaybookBodySchema2 = z92.object({
13495
14965
  */
13496
14966
  free_trial_rules: z92.array(RevTurbineConfigFreeTrialRuleItemSchema2).optional().meta({ ...Unrestricted52, ...PLAYBOOK_AUTHORING_FACETS22 }),
13497
14967
  reverse_trial_rules: z92.array(RevTurbineConfigReverseTrialRuleItemSchema2).optional().meta({ ...Unrestricted52, ...PLAYBOOK_AUTHORING_FACETS22 }),
14968
+ /**
14969
+ * Business objectives (BL-0176 / BL-0178, ruling D-15). Optional so every
14970
+ * Playbook authored before objectives existed still parses. Placements and
14971
+ * entitlement rules reference an entry by `handle` through their optional
14972
+ * `objective` field. Authoring-only: no runtime decision reads it, so it is
14973
+ * not an SDK input and never lowers into the IR.
14974
+ */
14975
+ objectives: z92.array(RevTurbineConfigObjectivesItemSchema2).optional().meta({ ...Unrestricted52, ...PLAYBOOK_AUTHORING_FACETS22 }),
13498
14976
  // Plan / add-on variation prices carried by handle (plan 118 TASK-16). These
13499
14977
  // live on the legacy schema (not just the canonical Playbook body) so that a
13500
14978
  // legacy `version`-shaped config — the shape the demo-data configs and the
@@ -13760,6 +15238,56 @@ var configPaths2 = {
13760
15238
  "x-revturbine-operation": { exposure: "internal", resource: "personalization-tokens", persistence: { table: "personalizationTokenVersions", mode: "delete" } }
13761
15239
  })
13762
15240
  },
15241
+ "/api/objective-anchors": {
15242
+ get: operation2({
15243
+ operationId: "listObjectiveAnchors",
15244
+ requestParams: { query: ListQueryParamsSchema2 },
15245
+ summary: "List objective anchors (identity registry)",
15246
+ tags: ["config"],
15247
+ responses: {
15248
+ "200": { description: "Objective anchor list", content: { "application/json": { schema: ListEnvelope2(ObjectiveAnchorSchema2) } } },
15249
+ default: { description: "Error response", content: { "application/json": { schema: ErrorEnvelope2 } } }
15250
+ },
15251
+ "x-revturbine-operation": { exposure: "internal", resource: "objective-anchors", persistence: { table: "objectives", mode: "list" } }
15252
+ })
15253
+ },
15254
+ "/api/config/objectives": {
15255
+ get: operation2({
15256
+ operationId: "listObjectives",
15257
+ requestParams: { query: ListQueryParamsSchema2 },
15258
+ summary: "List objectives",
15259
+ tags: ["config"],
15260
+ responses: { "200": { description: "Objective list", content: { "application/json": { schema: ListEnvelope2(ObjectiveSchema2) } } } },
15261
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "list" } }
15262
+ }),
15263
+ post: operation2({
15264
+ operationId: "createObjective",
15265
+ summary: "Create objective",
15266
+ tags: ["config"],
15267
+ requestBody: { required: true, content: { "application/json": { schema: toCreateSchema2(ObjectiveSchema2) } } },
15268
+ responses: { "201": { description: "Created", content: { "application/json": { schema: ObjectiveSchema2 } } } },
15269
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "create" } }
15270
+ })
15271
+ },
15272
+ "/api/config/objectives/{id}": {
15273
+ patch: operation2({
15274
+ operationId: "updateObjective",
15275
+ requestParams: { path: z92.object({ id: z92.string() }) },
15276
+ summary: "Update objective",
15277
+ tags: ["config"],
15278
+ requestBody: { required: true, content: { "application/json": { schema: ObjectiveSchema2.partial() } } },
15279
+ responses: { "200": { description: "Updated", content: { "application/json": { schema: ObjectiveSchema2 } } } },
15280
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "update" } }
15281
+ }),
15282
+ delete: operation2({
15283
+ operationId: "deleteObjective",
15284
+ requestParams: { path: z92.object({ id: z92.string() }) },
15285
+ summary: "Delete objective",
15286
+ tags: ["config"],
15287
+ responses: { "204": { description: "Deleted" } },
15288
+ "x-revturbine-operation": { exposure: "internal", resource: "objectives", persistence: { table: "objectiveVersions", mode: "delete" } }
15289
+ })
15290
+ },
13763
15291
  "/api/config/stripe": {
13764
15292
  get: operation2({
13765
15293
  operationId: "getStripeIntegrationConfig",
@@ -13934,7 +15462,7 @@ function repairDeprecatedFields(config, schema = PlaybookObjectSchema2) {
13934
15462
  }
13935
15463
 
13936
15464
  // src/schema/version.ts
13937
- var SCHEMA_VERSION = "0.1.325";
15465
+ var SCHEMA_VERSION = "0.1.362";
13938
15466
 
13939
15467
  // src/lib/credentials.ts
13940
15468
  import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
@@ -14223,7 +15751,9 @@ var COLLECTIONS = [
14223
15751
  "surface_templates",
14224
15752
  "placements",
14225
15753
  "free_trial_rules",
14226
- "reverse_trial_rules"
15754
+ "reverse_trial_rules",
15755
+ // BL-0176 / BL-0178: first-class objectives, keyed by handle.
15756
+ "objectives"
14227
15757
  ];
14228
15758
  function keyOf(item) {
14229
15759
  return item?.handle ?? item?.unique_handle ?? item?.name ?? item?.id ?? JSON.stringify(item);
@@ -14283,7 +15813,13 @@ function hasBlockingFindings(findings, force = false) {
14283
15813
  }
14284
15814
  function formatFindings(findings) {
14285
15815
  if (findings.length === 0) return "No validation findings.";
14286
- return findings.map((f) => `${isBlockingFinding(f) ? "\u2717" : "\u2022"} [${f.severity}] ${f.code}: ${f.message}`).join("\n");
15816
+ return findings.map((f) => {
15817
+ const blocking = isBlockingFinding(f);
15818
+ const head = `${blocking ? "\u2717" : "\u2022"} [${f.severity}] ${f.code}: ${f.message}`;
15819
+ const detail = f.detail?.trim();
15820
+ return blocking && detail ? `${head}
15821
+ ${detail}` : head;
15822
+ }).join("\n");
14287
15823
  }
14288
15824
  async function fetchValidation(baseUrl, playbookVersionId, headers, fetchImpl = fetch) {
14289
15825
  const res = await fetchImpl(`${baseUrl}/api/config/validate`, {