@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.
Files changed (2) hide show
  1. package/dist/cli.js +471 -14
  2. 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({ entitlement_handle: str() });
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 plansKnown = Array.isArray(graph.plans) && graph.plans.every((plan) => typeof plan.unique_handle === "string" && plan.unique_handle.length > 0);
10854
- const planHandles = new Set((graph.plans ?? []).map((plan) => plan.unique_handle));
10855
- for (const { payload, category, path: path3 } of placementPayloads(graph)) {
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.325";
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) => `${isBlockingFinding(f) ? "\u2717" : "\u2022"} [${f.severity}] ${f.code}: ${f.message}`).join("\n");
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",
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": {