@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.
- package/dist/cli.js +1578 -42
- 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
|
-
*
|
|
3946
|
+
* Personalization token map.
|
|
3896
3947
|
*
|
|
3897
3948
|
* Holds SDK-derived tokens (plan_name, usage_current, etc.) merged with
|
|
3898
|
-
* app-provided tokens
|
|
3899
|
-
*
|
|
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(
|
|
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
|
|
5068
|
-
* are NOT decorative: a concept that declares
|
|
5069
|
-
* has been mapped, and
|
|
5070
|
-
*
|
|
5071
|
-
*
|
|
5072
|
-
*
|
|
5073
|
-
*
|
|
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({
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
10854
|
-
const
|
|
10855
|
-
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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`, {
|