@revturbine/cli 0.19.3 → 0.19.4
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 +471 -14
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2353,6 +2353,7 @@ 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();
|
|
2356
2357
|
var SchemaContext = {
|
|
2357
2358
|
Playbook: "playbook",
|
|
2358
2359
|
Branding: "branding",
|
|
@@ -2998,6 +2999,12 @@ var EntitlementRuleSchema = IdField.merge(TimestampFields).merge(TenantIdField).
|
|
|
2998
2999
|
// "match all users" (replaces the legacy 'all' sentinel).
|
|
2999
3000
|
segment_ids: z6.array(z6.string()).default([]).meta(Unrestricted3),
|
|
3000
3001
|
visibility: RuleVisibilitySchema.default("public").meta(Unrestricted3),
|
|
3002
|
+
// Business objective this rule monetizes for — the analytics `objective`
|
|
3003
|
+
// slice, which resolves `rule_handle → objective` through a configuration
|
|
3004
|
+
// snapshot (BL-0065 / worksheet G4). Config-only, and deliberately NOT part
|
|
3005
|
+
// of the minted identity below: an objective relabel is a behaviour-only
|
|
3006
|
+
// edit that coalesces onto the same rule, not a new rule scope.
|
|
3007
|
+
objective: ObjectiveField.meta(Unrestricted3),
|
|
3001
3008
|
// Usage-Limit "measured over" window, rule-level (plan #55). Rate Limit
|
|
3002
3009
|
// keeps its entitlement-level `period_scope`; this is the per-rule one.
|
|
3003
3010
|
period_scope: UsagePeriodScopeSchema.optional().meta(Unrestricted3),
|
|
@@ -3351,6 +3358,13 @@ var PlacementSchema = IdField.merge(TimestampFields).merge(TenantIdField).merge(
|
|
|
3351
3358
|
handle: HandleField.meta(Unrestricted4),
|
|
3352
3359
|
description: DescriptionField.meta(Unrestricted4),
|
|
3353
3360
|
category: PlacementCategorySchema.meta(Unrestricted4),
|
|
3361
|
+
// Business objective this placement monetizes for — the analytics
|
|
3362
|
+
// `objective` slice, which resolves `placement_id → objective` through a
|
|
3363
|
+
// configuration snapshot (BL-0065 / worksheet G4). Config-only: nothing in
|
|
3364
|
+
// the runtime decision reads it, so it is absent from the portable
|
|
3365
|
+
// RevTurbineConfig projection and the compiled payload. Free handle; see
|
|
3366
|
+
// `ObjectiveField` for why the value set is not enumerated here.
|
|
3367
|
+
objective: ObjectiveField.meta(Unrestricted4),
|
|
3354
3368
|
drag_order_in_category: z7.number().int().default(0).meta(Unrestricted4),
|
|
3355
3369
|
// Trigger config (populated based on category)
|
|
3356
3370
|
surface_slot_id: z7.string().optional().meta(Unrestricted4),
|
|
@@ -5526,7 +5540,29 @@ var placementLifecycleBase = {
|
|
|
5526
5540
|
surface_slot_id: nstr(),
|
|
5527
5541
|
payload_id: nstr(),
|
|
5528
5542
|
decision_id: nstr(),
|
|
5529
|
-
decision_source: nstr()
|
|
5543
|
+
decision_source: nstr(),
|
|
5544
|
+
/**
|
|
5545
|
+
* The `unique_handle` of the entitlement rule / placement rule whose verdict
|
|
5546
|
+
* this event records; `null` when no rule matched (BL-0062, worksheet gap
|
|
5547
|
+
* G3). On the placement lane this is the winning placement entry's handle —
|
|
5548
|
+
* `PlacementOutput.rule_id`, already selected by the local resolver and
|
|
5549
|
+
* discarded at emit until now.
|
|
5550
|
+
*
|
|
5551
|
+
* Optional AND nullable, unlike the required-but-nullable siblings around
|
|
5552
|
+
* it. Every SDK already in the field emits these payloads without the key,
|
|
5553
|
+
* and `quarantineVerdict` (revturbine-web `src/lib/events/ingest-validation.ts`)
|
|
5554
|
+
* holds platform event names to this contract at the ingest boundary — a
|
|
5555
|
+
* required field would quarantine live telemetry from every deployed SDK.
|
|
5556
|
+
* So: absent = a pre-BL-0062 producer, `null` = no rule matched, string =
|
|
5557
|
+
* the winning rule.
|
|
5558
|
+
*
|
|
5559
|
+
* Why it rides the whole lifecycle base rather than `placement_resolved`
|
|
5560
|
+
* alone: attribution reads the exposure and outcome facts and stamps the
|
|
5561
|
+
* winning touch's keys onto the movement — "through it rule `usage_70pct`"
|
|
5562
|
+
* in the metric-primitives worked example (§3), anchored `touch_time` per
|
|
5563
|
+
* its R8. An exposure without the rule key cannot carry that.
|
|
5564
|
+
*/
|
|
5565
|
+
rule_handle: onstr()
|
|
5530
5566
|
};
|
|
5531
5567
|
var PlacementLifecyclePayload = z18.looseObject({ ...placementLifecycleBase });
|
|
5532
5568
|
var PlacementExposedPayload = z18.looseObject({
|
|
@@ -5551,7 +5587,29 @@ var PlacementInteractionPayload = z18.looseObject({
|
|
|
5551
5587
|
// explicit null when the caller supplied no timestamp (plan 228 TASK-4 —
|
|
5552
5588
|
// the typed emit surface surfaced the mismatch; the wire is the truth).
|
|
5553
5589
|
interaction_at: onstr(),
|
|
5554
|
-
remind_after_seconds: z18.number().optional().meta(Unrestricted15)
|
|
5590
|
+
remind_after_seconds: z18.number().optional().meta(Unrestricted15),
|
|
5591
|
+
/**
|
|
5592
|
+
* The `unique_handle` of the placement rule whose treatment the user acted
|
|
5593
|
+
* on — `PlacementOutput.rule_id`, the same value the lifecycle lane stamps
|
|
5594
|
+
* (BL-0182, follow-up to BL-0062 / #389).
|
|
5595
|
+
*
|
|
5596
|
+
* `placement_interaction` does not share `placementLifecycleBase` — its
|
|
5597
|
+
* placement keys are all optional, because a bare `trackInteraction` caller
|
|
5598
|
+
* may supply none of them — so #389's single edit to that base reached the
|
|
5599
|
+
* four lifecycle, five slot and three gate events and left the interaction
|
|
5600
|
+
* lane without a rule key. The effect was a funnel sliceable by rule at
|
|
5601
|
+
* exposure and at outcome but NOT at the click in between, which is the
|
|
5602
|
+
* step the CTR and click-through primitives are computed over.
|
|
5603
|
+
*
|
|
5604
|
+
* Same contract as #389 and for the same reason: optional AND nullable.
|
|
5605
|
+
* revturbine-web's `quarantineVerdict`
|
|
5606
|
+
* (`src/lib/events/ingest-validation.ts`) holds platform event names to
|
|
5607
|
+
* these contracts at the ingest boundary, so a required field would
|
|
5608
|
+
* quarantine live interaction telemetry from every SDK already deployed.
|
|
5609
|
+
* Absent = a pre-BL-0182 producer or an interaction with no decision in
|
|
5610
|
+
* scope, `null` = no rule matched, string = the winning rule.
|
|
5611
|
+
*/
|
|
5612
|
+
rule_handle: onstr()
|
|
5555
5613
|
});
|
|
5556
5614
|
var GateEvaluatedPayloadSchema = z18.looseObject({
|
|
5557
5615
|
entitlement_handle: str(),
|
|
@@ -5560,13 +5618,38 @@ var GateEvaluatedPayloadSchema = z18.looseObject({
|
|
|
5560
5618
|
reason: nstr(),
|
|
5561
5619
|
limit: nnum(),
|
|
5562
5620
|
used: nnum(),
|
|
5563
|
-
remaining: nnum()
|
|
5621
|
+
remaining: nnum(),
|
|
5622
|
+
/**
|
|
5623
|
+
* The `unique_handle` of the entitlement rule whose verdict this event
|
|
5624
|
+
* records; `null` when no rule matched (BL-0062, worksheet gap G3).
|
|
5625
|
+
*
|
|
5626
|
+
* This is the rule the §2.6.5 most-permissive selection picked — the one
|
|
5627
|
+
* whose `limit` / `enabled` / `allowance_value` produced the `outcome`
|
|
5628
|
+
* beside it. Without it the event says WHAT was decided and never WHICH
|
|
5629
|
+
* configured rule decided it, which is the rule slice, and the
|
|
5630
|
+
* `rule_handle → objective` hop the objective slice needs (BL-0065).
|
|
5631
|
+
*
|
|
5632
|
+
* Optional as well as nullable — see `placementLifecycleBase.rule_handle`
|
|
5633
|
+
* for why a required field would quarantine every deployed SDK's gate
|
|
5634
|
+
* telemetry at the ingest boundary.
|
|
5635
|
+
*/
|
|
5636
|
+
rule_handle: onstr()
|
|
5564
5637
|
}).meta(meta4("GateEvaluatedPayload"));
|
|
5565
5638
|
var GateAttemptedPayload = z18.looseObject({ entitlement_handle: str() });
|
|
5566
|
-
var GateAllowedPayload = z18.looseObject({
|
|
5639
|
+
var GateAllowedPayload = z18.looseObject({
|
|
5640
|
+
entitlement_handle: str(),
|
|
5641
|
+
/** The rule that granted. See `GateEvaluatedPayloadSchema.rule_handle`. */
|
|
5642
|
+
rule_handle: onstr()
|
|
5643
|
+
});
|
|
5567
5644
|
var GateDeniedPayload = z18.looseObject({
|
|
5568
5645
|
entitlement_handle: str(),
|
|
5569
|
-
reason: onstr()
|
|
5646
|
+
reason: onstr(),
|
|
5647
|
+
/**
|
|
5648
|
+
* The rule that denied — `null` on the two identity-shaped denials
|
|
5649
|
+
* (`no_plan_identity`, `no_matching_entitlement_rule`), where no rule
|
|
5650
|
+
* matched at all. See `GateEvaluatedPayloadSchema.rule_handle`.
|
|
5651
|
+
*/
|
|
5652
|
+
rule_handle: onstr()
|
|
5570
5653
|
});
|
|
5571
5654
|
var slotContextBase = {
|
|
5572
5655
|
surface_slot_id: nstr(),
|
|
@@ -5577,7 +5660,18 @@ var slotContextBase = {
|
|
|
5577
5660
|
category: nstr(),
|
|
5578
5661
|
decision_source: nstr(),
|
|
5579
5662
|
reason_codes: z18.array(z18.string()).meta(Unrestricted15),
|
|
5580
|
-
decision_id: nstr()
|
|
5663
|
+
decision_id: nstr(),
|
|
5664
|
+
/**
|
|
5665
|
+
* The placement rule that won this slot — `PlacementOutput.rule_id`, in
|
|
5666
|
+
* scope in `slotContext()` and dropped there until now (BL-0062, gap G3).
|
|
5667
|
+
*
|
|
5668
|
+
* Slot events are a verdict lane, not merely a render log: `slot_filled`
|
|
5669
|
+
* says a rule won, `slot_suppressed` / `slot_empty` say none did. Carrying
|
|
5670
|
+
* the handle is what lets "which rule keeps losing its slot" be asked at
|
|
5671
|
+
* all — `reason_codes` beside it names WHY, never WHOSE. `null` on the
|
|
5672
|
+
* terminal-empty events, absent on a pre-BL-0062 producer.
|
|
5673
|
+
*/
|
|
5674
|
+
rule_handle: onstr()
|
|
5581
5675
|
};
|
|
5582
5676
|
var SlotLifecyclePayload = z18.looseObject({ ...slotContextBase });
|
|
5583
5677
|
var SlotErrorPayload = z18.looseObject({
|
|
@@ -6473,6 +6567,30 @@ var TreatmentInteractionInputSchema = z24.object({
|
|
|
6473
6567
|
message_block_id: z24.string().min(1).optional().meta(Unrestricted20),
|
|
6474
6568
|
experiment_id: z24.string().min(1).optional().meta(Unrestricted20),
|
|
6475
6569
|
variant_key: z24.string().min(1).optional().meta(Unrestricted20),
|
|
6570
|
+
/**
|
|
6571
|
+
* The `unique_handle` of the rule whose treatment was presented (BL-0200).
|
|
6572
|
+
*
|
|
6573
|
+
* #391 put `rule_handle` on the clickstream `placement_interaction` EVENT
|
|
6574
|
+
* and deliberately kept it off this wire, so the BASE
|
|
6575
|
+
* `placement_exposure_attribution` row this contract writes had no rule and
|
|
6576
|
+
* `treatment-interaction-rows.ts` hard-coded `rule_handle: null`. Only an
|
|
6577
|
+
* exposure that an attributed conversion later enriched carried one, which
|
|
6578
|
+
* is why the attribution plane's rule cut could serve conversion-grain
|
|
6579
|
+
* metrics and had to refuse presentation-grain ones BY NAME: computed over
|
|
6580
|
+
* the attributed subset, `presented_accounts` would report the accounts
|
|
6581
|
+
* that converted and `conversion.rate` ~100%.
|
|
6582
|
+
*
|
|
6583
|
+
* Carrying it here makes the rule a property of the EXPOSURE, known at
|
|
6584
|
+
* presentation time by the only party that holds the resolved
|
|
6585
|
+
* `PlacementOutput`, so those cuts become honestly servable.
|
|
6586
|
+
*
|
|
6587
|
+
* Optional AND nullable, the same contract #389/#391 chose and for the same
|
|
6588
|
+
* reason: a required field would reject every already-deployed SDK's
|
|
6589
|
+
* interactions at the ingest boundary. Absent = a pre-BL-0200 producer or
|
|
6590
|
+
* an interaction with no decision in scope, `null` = no rule matched, a
|
|
6591
|
+
* string = the winning rule.
|
|
6592
|
+
*/
|
|
6593
|
+
rule_handle: z24.string().min(1).nullable().optional().meta(Unrestricted20),
|
|
6476
6594
|
/**
|
|
6477
6595
|
* Caller-declared test traffic (plan 164) — mirrors `TrackEvent.test` so
|
|
6478
6596
|
* the presentation/interaction feed (the dashboard denominators) carries
|
|
@@ -9925,6 +10043,14 @@ var EntitlementCheckResultSchema = z34.object({
|
|
|
9925
10043
|
used: z34.number().optional().meta(Unrestricted28),
|
|
9926
10044
|
/** `max(0, limit - used)`. */
|
|
9927
10045
|
remaining: z34.number().optional().meta(Unrestricted28),
|
|
10046
|
+
/**
|
|
10047
|
+
* The `unique_handle` of the entitlement rule this verdict came from —
|
|
10048
|
+
* the winner of the §2.6.5 most-permissive selection (BL-0062, gap G3).
|
|
10049
|
+
* Absent when no rule produced the verdict (unknown handle, no plan
|
|
10050
|
+
* identity, default policy). This is the value an SDK stamps as the
|
|
10051
|
+
* gate event's `rule_handle`, grounding the analytics rule slice.
|
|
10052
|
+
*/
|
|
10053
|
+
rule_handle: z34.string().optional().meta(Unrestricted28),
|
|
9928
10054
|
/** Upsell placement to render when entitlement is denied. */
|
|
9929
10055
|
placement: PlacementDecisionOutputSchema.optional().meta(Unrestricted28)
|
|
9930
10056
|
}).meta({ id: "EntitlementCheckResult", "x-revturbine-schema-persistence": Transient27, "x-revturbine-schema-exposure": External13 });
|
|
@@ -10799,11 +10925,72 @@ var CATALOG = {
|
|
|
10799
10925
|
// both means every user on every plan — legal and occasionally intended,
|
|
10800
10926
|
// but indistinguishable from an unfinished rule after authoring. Reverse
|
|
10801
10927
|
// trial rules can't hit this (premium_plan_id is required).
|
|
10928
|
+
// ── Dangling handle references (BL-0124) ─────────────────────────────────
|
|
10929
|
+
// Four references nothing checked: each fails silently at runtime (the
|
|
10930
|
+
// trigger never arms, the trial grants nothing, the rule targets nobody, the
|
|
10931
|
+
// chip lowers to a literal no user carries), so a Playbook with a typo
|
|
10932
|
+
// validated clean and shipped dead config. `error_launch` per spec §2 — "a
|
|
10933
|
+
// reference whose target is created in a later step" is legitimate mid-draft
|
|
10934
|
+
// and must never reach launch.
|
|
10935
|
+
"VAL-PLC-08": {
|
|
10936
|
+
id: "VAL-PLC-08",
|
|
10937
|
+
severity: "error_launch",
|
|
10938
|
+
message: "This placement triggers on an entitlement that does not exist.",
|
|
10939
|
+
specRef: "config-validation.md \xA75.2 (BL-0124)"
|
|
10940
|
+
},
|
|
10941
|
+
"VAL-PLC-09": {
|
|
10942
|
+
id: "VAL-PLC-09",
|
|
10943
|
+
severity: "error_launch",
|
|
10944
|
+
message: "This payload targets a segment chip that matches no segment or plan.",
|
|
10945
|
+
specRef: "config-validation.md \xA75.2 (BL-0124)"
|
|
10946
|
+
},
|
|
10947
|
+
"VAL-PLN-08": {
|
|
10948
|
+
id: "VAL-PLN-08",
|
|
10949
|
+
severity: "error_launch",
|
|
10950
|
+
message: "This entitlement rule targets a plan, add-on, or variation that does not exist.",
|
|
10951
|
+
specRef: "config-validation.md \xA75.3 (BL-0124)"
|
|
10952
|
+
},
|
|
10953
|
+
"VAL-TRL-05": {
|
|
10954
|
+
id: "VAL-TRL-05",
|
|
10955
|
+
severity: "error_launch",
|
|
10956
|
+
message: "This trial rule references a plan that does not exist.",
|
|
10957
|
+
specRef: "config-validation.md \xA75.5 (BL-0124)"
|
|
10958
|
+
},
|
|
10802
10959
|
"VAL-TRL-04": {
|
|
10803
10960
|
id: "VAL-TRL-04",
|
|
10804
10961
|
severity: "warning",
|
|
10805
10962
|
message: "Trial rule '{rule}' targets every user on every plan (no plan, no segment). Confirm the widest possible audience is intended.",
|
|
10806
10963
|
specRef: "config-validation.md \xA75.8 (promoted \u2014 plan 174 TASK-7)"
|
|
10964
|
+
},
|
|
10965
|
+
// entitlement rule → entitlement (BL-0164): same dangling-reference family as
|
|
10966
|
+
// the four above, just the one reference §5.9's original sweep missed.
|
|
10967
|
+
"VAL-ENT-01": {
|
|
10968
|
+
id: "VAL-ENT-01",
|
|
10969
|
+
severity: "error_launch",
|
|
10970
|
+
message: "This entitlement rule links an entitlement that does not exist.",
|
|
10971
|
+
specRef: "config-validation.md \xA75.3 (BL-0164)"
|
|
10972
|
+
},
|
|
10973
|
+
// BL-0168: VAL-PLC-07's `plansKnown` precondition (every plan row carries a
|
|
10974
|
+
// non-empty `unique_handle`) fails silently for a DB-shape plan row that
|
|
10975
|
+
// spells the identifier `handle` — the whole check goes dark for the
|
|
10976
|
+
// Playbook with nothing recording that it did not run. `warning`, never
|
|
10977
|
+
// blocking: it is a meta-finding about check coverage, not a config defect.
|
|
10978
|
+
"VAL-PLC-07-SKIPPED": {
|
|
10979
|
+
id: "VAL-PLC-07-SKIPPED",
|
|
10980
|
+
severity: "warning",
|
|
10981
|
+
message: "VAL-PLC-07 ('targets no configured plan') could not run: the plan catalog carries a row with no usable unique_handle.",
|
|
10982
|
+
specRef: "config-validation.md \xA75.2 (BL-0168)"
|
|
10983
|
+
},
|
|
10984
|
+
// BL-0168: the payload→placement parent lookup (`placementPayloads`) matches
|
|
10985
|
+
// a standalone `placement_payloads` row on `row.id === payload.placement_id`
|
|
10986
|
+
// only. A miss leaves `category` undefined, `categoryBucket` falls through to
|
|
10987
|
+
// 99, and VAL-PLC-06's Access-Gate/Fixed cap check silently never runs for
|
|
10988
|
+
// that payload. `warning`, never blocking, same rationale as above.
|
|
10989
|
+
"VAL-PLC-06-SKIPPED": {
|
|
10990
|
+
id: "VAL-PLC-06-SKIPPED",
|
|
10991
|
+
severity: "warning",
|
|
10992
|
+
message: "VAL-PLC-06 (ignored cap/cooldown settings) could not run: this payload's placement_id matches no configured placement.",
|
|
10993
|
+
specRef: "config-validation.md \xA75.2 (BL-0168)"
|
|
10807
10994
|
}
|
|
10808
10995
|
};
|
|
10809
10996
|
function getCatalogEntry(id) {
|
|
@@ -10832,28 +11019,285 @@ function runSemanticRules(graph, opts = {}) {
|
|
|
10832
11019
|
...checkPlacementAuthoring(graph),
|
|
10833
11020
|
...checkTrialRuleWidestAudience(graph),
|
|
10834
11021
|
...checkSegmentExperimentRefs(graph),
|
|
10835
|
-
...checkExperimentMetricRefs(graph, opts.knownMetricIds)
|
|
11022
|
+
...checkExperimentMetricRefs(graph, opts.knownMetricIds),
|
|
11023
|
+
...checkDanglingReferences(graph)
|
|
10836
11024
|
];
|
|
10837
11025
|
}
|
|
11026
|
+
function referenceNamespace(graph, table2, handleField) {
|
|
11027
|
+
const rows = graph[table2];
|
|
11028
|
+
if (!rows) return void 0;
|
|
11029
|
+
const known = /* @__PURE__ */ new Set();
|
|
11030
|
+
for (const row of rows) {
|
|
11031
|
+
const handle = row[handleField];
|
|
11032
|
+
if (typeof handle !== "string" || !handle) return void 0;
|
|
11033
|
+
known.add(handle);
|
|
11034
|
+
}
|
|
11035
|
+
return known;
|
|
11036
|
+
}
|
|
11037
|
+
function knownHandlesHint(known, label) {
|
|
11038
|
+
if (known.size === 0) return "";
|
|
11039
|
+
const sorted = [...known].sort();
|
|
11040
|
+
const shown = sorted.slice(0, 8);
|
|
11041
|
+
const rest = sorted.length > shown.length ? `, \u2026 (${sorted.length} total)` : "";
|
|
11042
|
+
return ` Declared ${label}: ${shown.map((h) => `'${h}'`).join(", ")}${rest}.`;
|
|
11043
|
+
}
|
|
11044
|
+
function firstStringField(row, fields) {
|
|
11045
|
+
for (const field of fields) {
|
|
11046
|
+
const value = row[field];
|
|
11047
|
+
if (typeof value === "string" && value.length > 0) return { field, value };
|
|
11048
|
+
}
|
|
11049
|
+
return void 0;
|
|
11050
|
+
}
|
|
11051
|
+
function checkDanglingReferences(graph) {
|
|
11052
|
+
return [
|
|
11053
|
+
...checkTriggerEntitlementRefs(graph),
|
|
11054
|
+
...checkTrialPlanRefs(graph),
|
|
11055
|
+
...checkEntitlementRuleTargetRefs(graph),
|
|
11056
|
+
...checkEntitlementRuleEntitlementRefs(graph),
|
|
11057
|
+
...checkPayloadSegmentChipRefs(graph)
|
|
11058
|
+
];
|
|
11059
|
+
}
|
|
11060
|
+
function checkTriggerEntitlementRefs(graph) {
|
|
11061
|
+
const known = referenceNamespace(graph, "entitlements", "unique_handle");
|
|
11062
|
+
if (!known) return [];
|
|
11063
|
+
const findings = [];
|
|
11064
|
+
for (const [index, placement] of (graph.placements ?? []).entries()) {
|
|
11065
|
+
if (!isRecord2(placement.trigger)) continue;
|
|
11066
|
+
const ref = firstStringField(placement.trigger, ["entitlement_handle", "entitlement_id"]);
|
|
11067
|
+
if (!ref || known.has(ref.value)) continue;
|
|
11068
|
+
const id = String(placement.handle ?? placement.id ?? "");
|
|
11069
|
+
findings.push(
|
|
11070
|
+
finding(
|
|
11071
|
+
"VAL-PLC-08",
|
|
11072
|
+
{
|
|
11073
|
+
object_type: "placement",
|
|
11074
|
+
object_id: id,
|
|
11075
|
+
field: `trigger.${ref.field}`,
|
|
11076
|
+
path: ["placements", index, "trigger", ref.field],
|
|
11077
|
+
studio: "placements"
|
|
11078
|
+
},
|
|
11079
|
+
{
|
|
11080
|
+
message: `Placement '${id}' triggers on entitlement '${ref.value}', which no entitlement declares.`,
|
|
11081
|
+
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")
|
|
11082
|
+
}
|
|
11083
|
+
)
|
|
11084
|
+
);
|
|
11085
|
+
}
|
|
11086
|
+
return findings;
|
|
11087
|
+
}
|
|
11088
|
+
var TRIAL_PLAN_FIELDS = ["plan_id", "convert_to_plan_id", "fallback_plan_id", "premium_plan_id"];
|
|
11089
|
+
function checkTrialPlanRefs(graph) {
|
|
11090
|
+
const known = referenceNamespace(graph, "plans", "unique_handle");
|
|
11091
|
+
if (!known) return [];
|
|
11092
|
+
const findings = [];
|
|
11093
|
+
for (const table2 of ["free_trial_rules", "reverse_trial_rules"]) {
|
|
11094
|
+
const objectType = table2 === "free_trial_rules" ? "free_trial_rule" : "reverse_trial_rule";
|
|
11095
|
+
for (const [index, rule] of (graph[table2] ?? []).entries()) {
|
|
11096
|
+
const id = String(rule.handle ?? rule.id ?? "");
|
|
11097
|
+
for (const field of TRIAL_PLAN_FIELDS) {
|
|
11098
|
+
const value = rule[field];
|
|
11099
|
+
if (typeof value !== "string" || value.length === 0 || known.has(value)) continue;
|
|
11100
|
+
findings.push(
|
|
11101
|
+
finding(
|
|
11102
|
+
"VAL-TRL-05",
|
|
11103
|
+
{
|
|
11104
|
+
object_type: objectType,
|
|
11105
|
+
object_id: id,
|
|
11106
|
+
field,
|
|
11107
|
+
path: [table2, index, field],
|
|
11108
|
+
studio: "trials"
|
|
11109
|
+
},
|
|
11110
|
+
{
|
|
11111
|
+
message: `Trial rule '${id || "unnamed"}' references plan '${value}' in ${field}, which no plan declares.`,
|
|
11112
|
+
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")
|
|
11113
|
+
}
|
|
11114
|
+
)
|
|
11115
|
+
);
|
|
11116
|
+
}
|
|
11117
|
+
}
|
|
11118
|
+
}
|
|
11119
|
+
return findings;
|
|
11120
|
+
}
|
|
11121
|
+
var TARGET_KIND_TABLES = {
|
|
11122
|
+
plan: { table: "plans", handleField: "unique_handle", label: "plan" },
|
|
11123
|
+
addon: { table: "addons", handleField: "unique_handle", label: "add-on" },
|
|
11124
|
+
plan_variation: { table: "plan_variations", handleField: "handle", label: "plan variation" },
|
|
11125
|
+
addon_variation: { table: "addon_variations", handleField: "handle", label: "add-on variation" }
|
|
11126
|
+
};
|
|
11127
|
+
function checkEntitlementRuleTargetRefs(graph) {
|
|
11128
|
+
const namespaces = /* @__PURE__ */ new Map();
|
|
11129
|
+
const namespaceFor = (kind) => {
|
|
11130
|
+
const spec = TARGET_KIND_TABLES[kind];
|
|
11131
|
+
if (!spec) return void 0;
|
|
11132
|
+
if (!namespaces.has(kind)) namespaces.set(kind, referenceNamespace(graph, spec.table, spec.handleField));
|
|
11133
|
+
return namespaces.get(kind);
|
|
11134
|
+
};
|
|
11135
|
+
const findings = [];
|
|
11136
|
+
for (const [index, rule] of (graph.entitlement_rules ?? []).entries()) {
|
|
11137
|
+
const id = String(rule.handle ?? rule.id ?? "");
|
|
11138
|
+
const refs = [];
|
|
11139
|
+
if (Array.isArray(rule.targets)) {
|
|
11140
|
+
for (const [targetIndex, target] of rule.targets.entries()) {
|
|
11141
|
+
if (!isRecord2(target) || typeof target.id !== "string" || target.id.length === 0) continue;
|
|
11142
|
+
const kind = typeof target.kind === "string" ? target.kind : "plan";
|
|
11143
|
+
refs.push({
|
|
11144
|
+
kind,
|
|
11145
|
+
value: target.id,
|
|
11146
|
+
field: `targets.${targetIndex}.id`,
|
|
11147
|
+
path: ["entitlement_rules", index, "targets", targetIndex, "id"]
|
|
11148
|
+
});
|
|
11149
|
+
}
|
|
11150
|
+
} else if (Array.isArray(rule.plan_ids)) {
|
|
11151
|
+
for (const [planIndex, planRef] of rule.plan_ids.entries()) {
|
|
11152
|
+
if (typeof planRef !== "string" || planRef.length === 0) continue;
|
|
11153
|
+
refs.push({
|
|
11154
|
+
kind: "plan",
|
|
11155
|
+
value: planRef,
|
|
11156
|
+
field: `plan_ids.${planIndex}`,
|
|
11157
|
+
path: ["entitlement_rules", index, "plan_ids", planIndex]
|
|
11158
|
+
});
|
|
11159
|
+
}
|
|
11160
|
+
} else if (typeof rule.plan_id === "string" && rule.plan_id.length > 0) {
|
|
11161
|
+
refs.push({
|
|
11162
|
+
kind: "plan",
|
|
11163
|
+
value: rule.plan_id,
|
|
11164
|
+
field: "plan_id",
|
|
11165
|
+
path: ["entitlement_rules", index, "plan_id"]
|
|
11166
|
+
});
|
|
11167
|
+
}
|
|
11168
|
+
for (const ref of refs) {
|
|
11169
|
+
const known = namespaceFor(ref.kind);
|
|
11170
|
+
if (!known || known.has(ref.value)) continue;
|
|
11171
|
+
const label = TARGET_KIND_TABLES[ref.kind]?.label ?? ref.kind;
|
|
11172
|
+
findings.push(
|
|
11173
|
+
finding(
|
|
11174
|
+
"VAL-PLN-08",
|
|
11175
|
+
{
|
|
11176
|
+
object_type: "entitlement_rule",
|
|
11177
|
+
object_id: id,
|
|
11178
|
+
field: ref.field,
|
|
11179
|
+
path: ref.path,
|
|
11180
|
+
studio: "plans-entitlements"
|
|
11181
|
+
},
|
|
11182
|
+
{
|
|
11183
|
+
message: `Entitlement rule '${id || "unnamed"}' targets ${label} '${ref.value}', which no ${label} declares.`,
|
|
11184
|
+
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`)
|
|
11185
|
+
}
|
|
11186
|
+
)
|
|
11187
|
+
);
|
|
11188
|
+
}
|
|
11189
|
+
}
|
|
11190
|
+
return findings;
|
|
11191
|
+
}
|
|
11192
|
+
function checkEntitlementRuleEntitlementRefs(graph) {
|
|
11193
|
+
const known = referenceNamespace(graph, "entitlements", "unique_handle");
|
|
11194
|
+
if (!known) return [];
|
|
11195
|
+
const findings = [];
|
|
11196
|
+
for (const [index, rule] of (graph.entitlement_rules ?? []).entries()) {
|
|
11197
|
+
const value = rule.entitlement_id;
|
|
11198
|
+
if (typeof value !== "string" || value.length === 0 || known.has(value)) continue;
|
|
11199
|
+
const id = String(rule.handle ?? rule.id ?? "");
|
|
11200
|
+
findings.push(
|
|
11201
|
+
finding(
|
|
11202
|
+
"VAL-ENT-01",
|
|
11203
|
+
{
|
|
11204
|
+
object_type: "entitlement_rule",
|
|
11205
|
+
object_id: id,
|
|
11206
|
+
field: "entitlement_id",
|
|
11207
|
+
path: ["entitlement_rules", index, "entitlement_id"],
|
|
11208
|
+
studio: "plans-entitlements"
|
|
11209
|
+
},
|
|
11210
|
+
{
|
|
11211
|
+
message: `Entitlement rule '${id || "unnamed"}' links entitlement '${value}', which no entitlement declares.`,
|
|
11212
|
+
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")
|
|
11213
|
+
}
|
|
11214
|
+
)
|
|
11215
|
+
);
|
|
11216
|
+
}
|
|
11217
|
+
return findings;
|
|
11218
|
+
}
|
|
11219
|
+
function payloadSegmentChips(payload, path3) {
|
|
11220
|
+
const target = isRecord2(payload.target) ? payload.target : void 0;
|
|
11221
|
+
if (target && Array.isArray(target.segment_chips)) {
|
|
11222
|
+
return { fields: ["target", "segment_chips"], path: [...path3, "target", "segment_chips"], chips: target.segment_chips };
|
|
11223
|
+
}
|
|
11224
|
+
if (Array.isArray(payload.target_segment_chips)) {
|
|
11225
|
+
return { fields: ["target_segment_chips"], path: [...path3, "target_segment_chips"], chips: payload.target_segment_chips };
|
|
11226
|
+
}
|
|
11227
|
+
return void 0;
|
|
11228
|
+
}
|
|
11229
|
+
function checkPayloadSegmentChipRefs(graph) {
|
|
11230
|
+
const segments = referenceNamespace(graph, "segments", "handle");
|
|
11231
|
+
const plans = referenceNamespace(graph, "plans", "unique_handle");
|
|
11232
|
+
if (!segments || !plans) return [];
|
|
11233
|
+
const findings = [];
|
|
11234
|
+
for (const { payload, path: path3 } of placementPayloads(graph)) {
|
|
11235
|
+
const chips = payloadSegmentChips(payload, path3);
|
|
11236
|
+
if (!chips) continue;
|
|
11237
|
+
const id = String(payload.payload_id ?? payload.id ?? "");
|
|
11238
|
+
for (const [chipIndex, raw] of chips.chips.entries()) {
|
|
11239
|
+
if (typeof raw !== "string") continue;
|
|
11240
|
+
const value = raw.trim();
|
|
11241
|
+
if (!value || segments.has(value) || plans.has(value)) continue;
|
|
11242
|
+
findings.push(
|
|
11243
|
+
finding(
|
|
11244
|
+
"VAL-PLC-09",
|
|
11245
|
+
{
|
|
11246
|
+
object_type: "placement_payload",
|
|
11247
|
+
object_id: id,
|
|
11248
|
+
field: `${chips.fields.join(".")}.${chipIndex}`,
|
|
11249
|
+
path: [...chips.path, chipIndex],
|
|
11250
|
+
studio: "placements"
|
|
11251
|
+
},
|
|
11252
|
+
{
|
|
11253
|
+
message: `Payload '${id}' targets segment chip '${value}', which matches no segment or plan.`,
|
|
11254
|
+
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")
|
|
11255
|
+
}
|
|
11256
|
+
)
|
|
11257
|
+
);
|
|
11258
|
+
}
|
|
11259
|
+
}
|
|
11260
|
+
return findings;
|
|
11261
|
+
}
|
|
10838
11262
|
function* placementPayloads(graph) {
|
|
10839
11263
|
const placements = graph.placements ?? [];
|
|
10840
11264
|
for (const [index, placement] of placements.entries()) {
|
|
10841
11265
|
if (!Array.isArray(placement.payloads)) continue;
|
|
10842
11266
|
for (const [payloadIndex, payload] of placement.payloads.entries()) {
|
|
10843
|
-
if (isRecord2(payload)) yield { payload, category: placement.category, path: ["placements", index, "payloads", payloadIndex] };
|
|
11267
|
+
if (isRecord2(payload)) yield { payload, category: placement.category, path: ["placements", index, "payloads", payloadIndex], parentFound: true };
|
|
10844
11268
|
}
|
|
10845
11269
|
}
|
|
10846
11270
|
for (const [index, payload] of (graph.placement_payloads ?? []).entries()) {
|
|
10847
11271
|
const placement = placements.find((row) => typeof row.id === "string" && row.id === payload.placement_id);
|
|
10848
|
-
yield { payload, category: placement?.category, path: ["placement_payloads", index] };
|
|
11272
|
+
yield { payload, category: placement?.category, path: ["placement_payloads", index], parentFound: placement !== void 0 };
|
|
10849
11273
|
}
|
|
10850
11274
|
}
|
|
10851
11275
|
function checkPlacementAuthoring(graph) {
|
|
10852
11276
|
const findings = [];
|
|
10853
|
-
const
|
|
10854
|
-
const
|
|
10855
|
-
|
|
11277
|
+
const plans = graph.plans;
|
|
11278
|
+
const plansKnown = Array.isArray(plans) && plans.every((plan) => typeof plan.unique_handle === "string" && plan.unique_handle.length > 0);
|
|
11279
|
+
const planHandles = new Set((plans ?? []).map((plan) => plan.unique_handle));
|
|
11280
|
+
if (Array.isArray(plans) && plans.length > 0 && !plansKnown) {
|
|
11281
|
+
findings.push(finding("VAL-PLC-07-SKIPPED", {
|
|
11282
|
+
object_type: "plan",
|
|
11283
|
+
studio: "placements"
|
|
11284
|
+
}, {
|
|
11285
|
+
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."
|
|
11286
|
+
}));
|
|
11287
|
+
}
|
|
11288
|
+
for (const { payload, category, path: path3, parentFound } of placementPayloads(graph)) {
|
|
10856
11289
|
const id = String(payload.payload_id ?? payload.id ?? "");
|
|
11290
|
+
if (!parentFound) {
|
|
11291
|
+
findings.push(finding("VAL-PLC-06-SKIPPED", {
|
|
11292
|
+
object_type: "placement_payload",
|
|
11293
|
+
object_id: id,
|
|
11294
|
+
field: "placement_id",
|
|
11295
|
+
studio: "placements"
|
|
11296
|
+
}, {
|
|
11297
|
+
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.`,
|
|
11298
|
+
detail: "Fix placement_id (or add the placement it should reference) to restore the cap check for Access Gate and Fixed placements."
|
|
11299
|
+
}));
|
|
11300
|
+
}
|
|
10857
11301
|
const bucket = typeof category === "string" ? categoryBucket(category) : 99;
|
|
10858
11302
|
if (bucket === 0 || bucket === 1) {
|
|
10859
11303
|
const caps = isRecord2(payload.caps) ? payload.caps : {};
|
|
@@ -11506,6 +11950,7 @@ var CtaActionTypeSchema2 = z39.enum([
|
|
|
11506
11950
|
"snooze",
|
|
11507
11951
|
"custom"
|
|
11508
11952
|
]).meta({ id: "CtaActionType", "x-revturbine-schema-persistence": Transient30, "x-revturbine-schema-exposure": External14 });
|
|
11953
|
+
var ObjectiveField2 = HandleField2.optional();
|
|
11509
11954
|
var IdentityKind2 = {
|
|
11510
11955
|
/** Author-given, human-meaningful handle (plans, entitlements, segments, …). */
|
|
11511
11956
|
Named: "named",
|
|
@@ -11711,6 +12156,12 @@ var EntitlementRuleSchema2 = IdField2.merge(TimestampFields2).merge(TenantIdFiel
|
|
|
11711
12156
|
// "match all users" (replaces the legacy 'all' sentinel).
|
|
11712
12157
|
segment_ids: z62.array(z62.string()).default([]).meta(Unrestricted210),
|
|
11713
12158
|
visibility: RuleVisibilitySchema2.default("public").meta(Unrestricted210),
|
|
12159
|
+
// Business objective this rule monetizes for — the analytics `objective`
|
|
12160
|
+
// slice, which resolves `rule_handle → objective` through a configuration
|
|
12161
|
+
// snapshot (BL-0065 / worksheet G4). Config-only, and deliberately NOT part
|
|
12162
|
+
// of the minted identity below: an objective relabel is a behaviour-only
|
|
12163
|
+
// edit that coalesces onto the same rule, not a new rule scope.
|
|
12164
|
+
objective: ObjectiveField2.meta(Unrestricted210),
|
|
11714
12165
|
// Usage-Limit "measured over" window, rule-level (plan #55). Rate Limit
|
|
11715
12166
|
// keeps its entitlement-level `period_scope`; this is the per-rule one.
|
|
11716
12167
|
period_scope: UsagePeriodScopeSchema2.optional().meta(Unrestricted210),
|
|
@@ -13934,7 +14385,7 @@ function repairDeprecatedFields(config, schema = PlaybookObjectSchema2) {
|
|
|
13934
14385
|
}
|
|
13935
14386
|
|
|
13936
14387
|
// src/schema/version.ts
|
|
13937
|
-
var SCHEMA_VERSION = "0.1.
|
|
14388
|
+
var SCHEMA_VERSION = "0.1.343";
|
|
13938
14389
|
|
|
13939
14390
|
// src/lib/credentials.ts
|
|
13940
14391
|
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
@@ -14283,7 +14734,13 @@ function hasBlockingFindings(findings, force = false) {
|
|
|
14283
14734
|
}
|
|
14284
14735
|
function formatFindings(findings) {
|
|
14285
14736
|
if (findings.length === 0) return "No validation findings.";
|
|
14286
|
-
return findings.map((f) =>
|
|
14737
|
+
return findings.map((f) => {
|
|
14738
|
+
const blocking = isBlockingFinding(f);
|
|
14739
|
+
const head = `${blocking ? "\u2717" : "\u2022"} [${f.severity}] ${f.code}: ${f.message}`;
|
|
14740
|
+
const detail = f.detail?.trim();
|
|
14741
|
+
return blocking && detail ? `${head}
|
|
14742
|
+
${detail}` : head;
|
|
14743
|
+
}).join("\n");
|
|
14287
14744
|
}
|
|
14288
14745
|
async function fetchValidation(baseUrl, playbookVersionId, headers, fetchImpl = fetch) {
|
|
14289
14746
|
const res = await fetchImpl(`${baseUrl}/api/config/validate`, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revturbine/cli",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.4",
|
|
4
4
|
"description": "revturbine — validate RevTurbine Playbooks and ship them to a RevTurbine instance through the playbook-version lifecycle (draft → Release).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|