@revturbine/cli 0.19.2 → 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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +694 -45
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -80,7 +80,7 @@ Commands that read a config name the version explicitly — there is no default:
80
80
  | `status` | The live Release and the open draft, side by side. |
81
81
  | `history` | The Release Version Log, newest first. |
82
82
  | `preview` | The open draft's staged changes. |
83
- | `evaluate` | Run a config version's placement/entitlement decisions locally for a user context. Use `--slot` with optional `--component-type`; deprecated `--surface-type` remains an alias. |
83
+ | `evaluate` | Run a config version's placement/entitlement decisions locally for a user context. Use `--slot` with optional `--component-type`; deprecated `--surface-type` remains an alias. The ctx file carries the whole decision context — `plan_handle`, `traits`, `usage`, `trial` (or `trial_instances` + `now_iso`) — and `--usage` / `--trial-status` / `--now` override those keys, so usage-threshold and trial placements decide here exactly as they do in an app. Output reports `payload_ids`: which authored payload the resolver chose per placement, plus the SDK's `personalization_tokens`. |
84
84
  | `generate types` | Generate a TypeScript module of typed Playbook handles from any config version — `Entitlements` (namespaced by type, with the `EntitlementHandle` union for type-safe `can()`/`gate()`/`checkEntitlement()` call sites), plus `Plans`, `Segments`, `SurfaceTemplates`, and `UiPathActionTypes`. Const objects + literal-union types (erasable — no enums). `--out <path>` writes the file; the generated header records the exact command to regenerate it. `--json` for the raw handle map. |
85
85
  | `analytics catalog\|templates\|views\|view\|create\|preview\|query` | Work with the hosted Semantic Catalog and canonical analytics-view documents. Create and preview pass the document through unchanged to the same server contract used by the web editor and MCP tools; preview remains subject to the server's query limits. |
86
86
  | `ingest-keys create` (alias `mint`) | Mint a tenant-bound public ingest token for browser SDK use. Requires one or more `--origin` values; optional `--ip` restrictions. The full token is returned once. `ingest-keys list` shows ids/previews and `ingest-keys revoke <id>` invalidates one. |
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)
11024
+ ];
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)
10836
11058
  ];
10837
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`, {
@@ -14390,28 +14847,181 @@ function formatAuthentication(hasToken, tokenValid) {
14390
14847
  return "authentication: verification unavailable \u2014 credentials present; retry when the instance is reachable";
14391
14848
  }
14392
14849
 
14850
+ // src/lib/evaluate-context.ts
14851
+ function isRecord4(value) {
14852
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14853
+ }
14854
+ var DEFAULT_USAGE_UNIT = "unit";
14855
+ function normalizeUsageEntry(handle, value, errors) {
14856
+ if (typeof value === "number") {
14857
+ if (!Number.isFinite(value) || value < 0) {
14858
+ errors.push(`usage.${handle} must be a finite number \u2265 0`);
14859
+ return void 0;
14860
+ }
14861
+ return { entitlement_handle: handle, unit: DEFAULT_USAGE_UNIT, amount: value };
14862
+ }
14863
+ if (!isRecord4(value)) {
14864
+ errors.push(`usage.${handle} must be a number or an object with "amount"`);
14865
+ return void 0;
14866
+ }
14867
+ const amount = value.amount;
14868
+ if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) {
14869
+ errors.push(`usage.${handle}.amount must be a finite number \u2265 0`);
14870
+ return void 0;
14871
+ }
14872
+ const entry = {
14873
+ entitlement_handle: typeof value.entitlement_handle === "string" ? value.entitlement_handle : handle,
14874
+ unit: typeof value.unit === "string" && value.unit.length > 0 ? value.unit : DEFAULT_USAGE_UNIT,
14875
+ amount
14876
+ };
14877
+ if (value.limit !== void 0) {
14878
+ if (typeof value.limit !== "number" || !Number.isFinite(value.limit) || value.limit < 0) {
14879
+ errors.push(`usage.${handle}.limit must be a finite number \u2265 0`);
14880
+ } else {
14881
+ entry.limit = value.limit;
14882
+ }
14883
+ }
14884
+ if (value.reset_date !== void 0) {
14885
+ if (typeof value.reset_date !== "string") {
14886
+ errors.push(`usage.${handle}.reset_date must be a string`);
14887
+ } else {
14888
+ entry.reset_date = value.reset_date;
14889
+ }
14890
+ }
14891
+ return entry;
14892
+ }
14893
+ function normalizeUsage(value, errors) {
14894
+ if (value === void 0 || value === null) return void 0;
14895
+ if (!isRecord4(value)) {
14896
+ errors.push("usage must be an object keyed by entitlement handle");
14897
+ return void 0;
14898
+ }
14899
+ const out = {};
14900
+ for (const [handle, raw] of Object.entries(value)) {
14901
+ const entry = normalizeUsageEntry(handle, raw, errors);
14902
+ if (entry) out[handle] = entry;
14903
+ }
14904
+ return Object.keys(out).length > 0 ? out : void 0;
14905
+ }
14906
+ function normalizeTrialStatus(value, errors) {
14907
+ if (value === void 0 || value === null) return void 0;
14908
+ if (!isRecord4(value)) {
14909
+ errors.push("trial status must be an object (the SDK's UserTrialStatus)");
14910
+ return void 0;
14911
+ }
14912
+ if (typeof value.in_trial !== "boolean") {
14913
+ errors.push('trial status must carry a boolean "in_trial"');
14914
+ return void 0;
14915
+ }
14916
+ return value;
14917
+ }
14918
+ function normalizeTrialInstances(value, errors) {
14919
+ if (value === void 0 || value === null) return void 0;
14920
+ if (!Array.isArray(value) || value.some((item) => !isRecord4(item))) {
14921
+ errors.push("trial_instances must be an array of TrialInstance objects");
14922
+ return void 0;
14923
+ }
14924
+ return value.length > 0 ? value : void 0;
14925
+ }
14926
+ function normalizeNowIso(value, source, errors) {
14927
+ if (value === void 0 || value === null) return void 0;
14928
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
14929
+ errors.push(`${source} must be an ISO-8601 timestamp (e.g. 2026-09-21T00:00:00Z)`);
14930
+ return void 0;
14931
+ }
14932
+ return new Date(value).toISOString();
14933
+ }
14934
+ function parseJsonFlag(raw, flag, errors) {
14935
+ try {
14936
+ return JSON.parse(raw);
14937
+ } catch (err) {
14938
+ errors.push(`${flag} must be valid JSON: ${err.message}`);
14939
+ return void 0;
14940
+ }
14941
+ }
14942
+ function parseDecisionContext(input) {
14943
+ const errors = [];
14944
+ const usageSource = input.usage !== void 0 ? parseJsonFlag(input.usage, "--usage", errors) : input.ctx.usage;
14945
+ const usage = normalizeUsage(usageSource, errors);
14946
+ const trialSource = input.trialStatus !== void 0 ? parseJsonFlag(input.trialStatus, "--trial-status", errors) : input.ctx.trial;
14947
+ const trialStatus = normalizeTrialStatus(trialSource, errors);
14948
+ const trialInstances = normalizeTrialInstances(input.ctx.trial_instances, errors);
14949
+ const nowIso = input.now !== void 0 ? normalizeNowIso(input.now, "--now", errors) : normalizeNowIso(input.ctx.now_iso, "now_iso", errors);
14950
+ if (errors.length > 0) return { ok: false, errors };
14951
+ return {
14952
+ ok: true,
14953
+ context: {
14954
+ ...usage ? { usage } : {},
14955
+ ...trialStatus ? { trialStatus } : {},
14956
+ ...trialInstances ? { trialInstances } : {},
14957
+ ...nowIso ? { nowIso } : {}
14958
+ }
14959
+ };
14960
+ }
14961
+
14393
14962
  // src/lib/evaluate-local.ts
14394
14963
  import { initRevTurbine } from "@revturbine/sdk/headless";
14395
14964
  function resolvePlacementComponentType(input) {
14396
14965
  return input.componentType ?? input.surfaceType;
14397
14966
  }
14967
+ function payloadIdOf(decision) {
14968
+ if (typeof decision !== "object" || decision === null) return null;
14969
+ const output = decision.output;
14970
+ if (typeof output !== "object" || output === null) return null;
14971
+ const id = output.output_id;
14972
+ return typeof id === "string" ? id : null;
14973
+ }
14974
+ async function completeUsageLimits(session, usage) {
14975
+ let completed = false;
14976
+ const filled = {};
14977
+ for (const [handle, entry] of Object.entries(usage)) {
14978
+ if (entry.limit !== void 0) {
14979
+ filled[handle] = entry;
14980
+ continue;
14981
+ }
14982
+ const result = await session.checkEntitlement(entry.entitlement_handle);
14983
+ if (typeof result?.limit === "number" && Number.isFinite(result.limit)) {
14984
+ filled[handle] = { ...entry, limit: result.limit };
14985
+ completed = true;
14986
+ } else {
14987
+ filled[handle] = entry;
14988
+ }
14989
+ }
14990
+ if (completed) session.setUserContext({ usage: filled });
14991
+ }
14398
14992
  async function evaluateLocal(config, input) {
14399
14993
  const user = { id: input.userId };
14400
14994
  if (input.planHandle) user.plan_handle = input.planHandle;
14401
14995
  if (input.traits && Object.keys(input.traits).length > 0) user.custom = input.traits;
14996
+ if (input.usage) user.usage = input.usage;
14402
14997
  const session = await initRevTurbine({
14403
14998
  user,
14404
- localRuntime: { playbook: config }
14999
+ localRuntime: {
15000
+ playbook: config,
15001
+ ...input.trialStatus ? { initialData: { trialStatus: input.trialStatus } } : {}
15002
+ }
14405
15003
  });
15004
+ if (input.trialInstances) {
15005
+ await session.sdk.setTrialInstances(
15006
+ input.trialInstances,
15007
+ input.nowIso ? { nowIso: input.nowIso } : void 0
15008
+ );
15009
+ }
15010
+ const trialAsked = Boolean(input.trialInstances || input.trialStatus);
15011
+ const trialStatus = trialAsked ? await session.getTrialStatus() : void 0;
15012
+ if (input.usage) await completeUsageLimits(session, input.usage);
14406
15013
  const entitlements = {};
14407
15014
  for (const handle of input.entitlementHandles ?? []) {
14408
15015
  entitlements[handle] = await session.checkEntitlement(handle);
14409
15016
  }
14410
15017
  const decisions = [];
15018
+ const payloadIds = {};
14411
15019
  for (const placementId of input.placementIds ?? []) {
14412
15020
  const controller = session.placement({ placement: { name: placementId } });
14413
15021
  await controller.load();
14414
- decisions.push(controller.state.decision);
15022
+ const decision = controller.state.decision;
15023
+ decisions.push(decision);
15024
+ payloadIds[placementId] = payloadIdOf(decision);
14415
15025
  }
14416
15026
  const componentType = input.slot ? resolvePlacementComponentType(input.slot) : void 0;
14417
15027
  const placement = input.slot && (input.slot.slotId || componentType) ? await session.getPlacement({
@@ -14419,7 +15029,14 @@ async function evaluateLocal(config, input) {
14419
15029
  ...componentType ? { componentType } : {},
14420
15030
  ...input.planHandle ? { planHandle: input.planHandle } : {}
14421
15031
  }) : null;
14422
- return { decisions, entitlements, placement };
15032
+ return {
15033
+ decisions,
15034
+ payloadIds,
15035
+ entitlements,
15036
+ placement,
15037
+ personalizationTokens: session.sdk.getPersonalizationTokens(),
15038
+ ...trialStatus !== void 0 ? { trialStatus } : {}
15039
+ };
14423
15040
  }
14424
15041
 
14425
15042
  // src/lib/ingest-keys.ts
@@ -14665,14 +15282,14 @@ function schemaForConfig(raw) {
14665
15282
  var repairDeprecatedFields2 = repairDeprecatedFields;
14666
15283
  var LEGACY_HEADER_ALIASES = /* @__PURE__ */ new Set(["version", "change_set_id"]);
14667
15284
  var UNKNOWN_FIELD_CODE = "cli/unknown-field";
14668
- function isRecord4(value) {
15285
+ function isRecord5(value) {
14669
15286
  return typeof value === "object" && value !== null && !Array.isArray(value);
14670
15287
  }
14671
15288
  function unknownTopLevelKeys(raw) {
14672
- if (!isRecord4(raw)) return [];
15289
+ if (!isRecord5(raw)) return [];
14673
15290
  const { schema } = schemaForConfig(raw);
14674
15291
  const parsed = schema.safeParse(raw);
14675
- if (!parsed.success || !isRecord4(parsed.data)) return [];
15292
+ if (!parsed.success || !isRecord5(parsed.data)) return [];
14676
15293
  const recognized = new Set(Object.keys(parsed.data));
14677
15294
  return Object.keys(raw).filter((key2) => !recognized.has(key2) && !LEGACY_HEADER_ALIASES.has(key2));
14678
15295
  }
@@ -14916,17 +15533,17 @@ function finalOutputLines(params) {
14916
15533
 
14917
15534
  // src/lib/handles-codegen.ts
14918
15535
  var UNTYPED_GROUP = "untyped";
14919
- function isRecord5(value) {
15536
+ function isRecord6(value) {
14920
15537
  return typeof value === "object" && value !== null && !Array.isArray(value);
14921
15538
  }
14922
15539
  function nonEmptyString(value) {
14923
15540
  return typeof value === "string" && value.length > 0 ? value : null;
14924
15541
  }
14925
15542
  function extractEntitlementHandles(config) {
14926
- if (!isRecord5(config) || !Array.isArray(config.entitlements)) return {};
15543
+ if (!isRecord6(config) || !Array.isArray(config.entitlements)) return {};
14927
15544
  const groups = /* @__PURE__ */ new Map();
14928
15545
  for (const entry of config.entitlements) {
14929
- if (!isRecord5(entry)) continue;
15546
+ if (!isRecord6(entry)) continue;
14930
15547
  const handle = nonEmptyString(entry.unique_handle) ?? nonEmptyString(entry.handle);
14931
15548
  if (!handle) continue;
14932
15549
  const type = nonEmptyString(entry.type) ?? UNTYPED_GROUP;
@@ -14996,36 +15613,36 @@ function collectStrings(values) {
14996
15613
  return [...out].sort();
14997
15614
  }
14998
15615
  function extractPlanHandles(config) {
14999
- if (!isRecord5(config) || !Array.isArray(config.plans)) return [];
15616
+ if (!isRecord6(config) || !Array.isArray(config.plans)) return [];
15000
15617
  return collectStrings(
15001
15618
  config.plans.map(
15002
- (p) => isRecord5(p) ? nonEmptyString(p.unique_handle) ?? nonEmptyString(p.handle) ?? p.id : null
15619
+ (p) => isRecord6(p) ? nonEmptyString(p.unique_handle) ?? nonEmptyString(p.handle) ?? p.id : null
15003
15620
  )
15004
15621
  );
15005
15622
  }
15006
15623
  function extractSegmentHandles(config) {
15007
- if (!isRecord5(config) || !Array.isArray(config.segments)) return [];
15624
+ if (!isRecord6(config) || !Array.isArray(config.segments)) return [];
15008
15625
  return collectStrings(
15009
- config.segments.map((s) => isRecord5(s) ? nonEmptyString(s.handle) ?? s.id : null)
15626
+ config.segments.map((s) => isRecord6(s) ? nonEmptyString(s.handle) ?? s.id : null)
15010
15627
  );
15011
15628
  }
15012
15629
  function extractSurfaceTemplateIds(config) {
15013
- if (!isRecord5(config)) return [];
15630
+ if (!isRecord6(config)) return [];
15014
15631
  const ids = [];
15015
15632
  if (Array.isArray(config.surface_templates)) {
15016
15633
  for (const t of config.surface_templates) {
15017
- if (isRecord5(t)) ids.push(t.id ?? t.template_id);
15634
+ if (isRecord6(t)) ids.push(t.id ?? t.template_id);
15018
15635
  }
15019
15636
  }
15020
15637
  const surfacesOf = (payload) => {
15021
- if (!isRecord5(payload) || !Array.isArray(payload.surfaces)) return;
15638
+ if (!isRecord6(payload) || !Array.isArray(payload.surfaces)) return;
15022
15639
  for (const surface of payload.surfaces) {
15023
- if (isRecord5(surface)) ids.push(surface.template_id);
15640
+ if (isRecord6(surface)) ids.push(surface.template_id);
15024
15641
  }
15025
15642
  };
15026
15643
  if (Array.isArray(config.placements)) {
15027
15644
  for (const placement of config.placements) {
15028
- if (!isRecord5(placement) || !Array.isArray(placement.payloads)) continue;
15645
+ if (!isRecord6(placement) || !Array.isArray(placement.payloads)) continue;
15029
15646
  for (const payload of placement.payloads) surfacesOf(payload);
15030
15647
  }
15031
15648
  }
@@ -15035,9 +15652,9 @@ function extractSurfaceTemplateIds(config) {
15035
15652
  return collectStrings(ids);
15036
15653
  }
15037
15654
  function extractUiPathActionTypes(config) {
15038
- if (!isRecord5(config) || !Array.isArray(config.content_ui_paths)) return [];
15655
+ if (!isRecord6(config) || !Array.isArray(config.content_ui_paths)) return [];
15039
15656
  return collectStrings(
15040
- config.content_ui_paths.map((p) => isRecord5(p) ? p.action_type : null)
15657
+ config.content_ui_paths.map((p) => isRecord6(p) ? p.action_type : null)
15041
15658
  );
15042
15659
  }
15043
15660
  function renderFlatFamily(lines, opts) {
@@ -15139,19 +15756,19 @@ function fail(cls, message) {
15139
15756
  }
15140
15757
 
15141
15758
  // src/lib/pin-drift.ts
15142
- function isRecord6(value) {
15759
+ function isRecord7(value) {
15143
15760
  return typeof value === "object" && value !== null && !Array.isArray(value);
15144
15761
  }
15145
15762
  function pinOf(pkg, name) {
15146
15763
  for (const field of ["devDependencies", "dependencies"]) {
15147
15764
  const deps = pkg[field];
15148
- if (isRecord6(deps) && typeof deps[name] === "string") return deps[name];
15765
+ if (isRecord7(deps) && typeof deps[name] === "string") return deps[name];
15149
15766
  }
15150
15767
  return void 0;
15151
15768
  }
15152
15769
  var EXACT = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
15153
15770
  function checkPinDrift(pkg) {
15154
- if (!isRecord6(pkg)) return [];
15771
+ if (!isRecord7(pkg)) return [];
15155
15772
  const warnings = [];
15156
15773
  const cliPin = pinOf(pkg, "@revturbine/cli");
15157
15774
  if (cliPin !== void 0 && !EXACT.test(cliPin)) {
@@ -16223,8 +16840,8 @@ program.command("preview").description("The open draft's Runtime Impact summary
16223
16840
  emit(data, Boolean(opts.json), text);
16224
16841
  });
16225
16842
  program.command("evaluate").description(
16226
- "Run placement/entitlement decisions for a user context against a config version \u2014 evaluated LOCALLY: the CLI fetches the version's Playbook and runs the SDK engine in-process (plan 192; clean-room, no suppression/cap history). Requires --live, --draft, or --release <id>."
16227
- ).option("--live", "Evaluate the live (deployed) configuration").option("--draft", "Evaluate the tenant's open draft").option("--release <id>", "Evaluate a past release (from its frozen snapshot)").option("--entitlement <handle>", "Check one entitlement (the checkEntitlement result)").option("--slot <id>", "Evaluate one surface slot (the getPlacement decision for that slot)").option("--component-type <type>", "Disambiguate a slot that can render more than one component, or resolve by component type alone").option("--surface-type <type>", "Deprecated alias for --component-type").option("--plan-handle <handle>", "Evaluate as if the user were on this plan (overrides the ctx file)").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).requiredOption("--user <file>", "JSON file: { user_id, customer_id?, plan_handle?, traits?, now_iso? }").option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(
16843
+ "Run placement/entitlement decisions for a user context against a config version \u2014 evaluated LOCALLY: the CLI fetches the version's Playbook and runs the SDK engine in-process (plan 192; clean-room, no suppression/cap history). The ctx file carries the full decision context \u2014 plan, traits, usage, trial state and a clock pin \u2014 so usage-threshold and trial placements decide here as they do in an app. Requires --live, --draft, or --release <id>."
16844
+ ).option("--live", "Evaluate the live (deployed) configuration").option("--draft", "Evaluate the tenant's open draft").option("--release <id>", "Evaluate a past release (from its frozen snapshot)").option("--entitlement <handle>", "Check one entitlement (the checkEntitlement result)").option("--slot <id>", "Evaluate one surface slot (the getPlacement decision for that slot)").option("--component-type <type>", "Disambiguate a slot that can render more than one component, or resolve by component type alone").option("--surface-type <type>", "Deprecated alias for --component-type").option("--plan-handle <handle>", "Evaluate as if the user were on this plan (overrides the ctx file)").option("--usage <json>", `Usage entries keyed by entitlement handle, e.g. '{"api_calls":{"amount":95}}' (overrides the ctx file) \u2014 required to decide usage/credit-threshold placements`).option("--trial-status <json>", `Trial status, e.g. '{"in_trial":true,"trial_limit_type":"time","days_remaining":3}' (overrides the ctx file) \u2014 required to decide trial placements`).option("--now <iso>", "Pin the clock (ISO-8601) for trial derivation from the ctx file's trial_instances (overrides ctx now_iso)").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).requiredOption("--user <file>", "JSON file: { user_id, plan_handle?, traits?, usage?, trial?, trial_instances?, now_iso? }").option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(
16228
16845
  async (opts) => {
16229
16846
  const [sel] = requireSelectors(opts, [], { count: 1, allowed: ["draft", "live", "release"], command: "evaluate" });
16230
16847
  const conn = connect(opts.url, opts.tenantId);
@@ -16242,8 +16859,18 @@ program.command("evaluate").description(
16242
16859
  }
16243
16860
  const userId = typeof ctx.user_id === "string" && ctx.user_id.length > 0 ? ctx.user_id : void 0;
16244
16861
  if (!userId) fail(EXIT.VALIDATION, `user file must carry a non-empty string "user_id" (${opts.user})`);
16245
- if (typeof ctx.now_iso === "string") {
16246
- diag("WARNING: now_iso is ignored \u2014 local evaluation uses the real clock.");
16862
+ const parsed = parseDecisionContext({
16863
+ ctx,
16864
+ usage: opts.usage,
16865
+ trialStatus: opts.trialStatus,
16866
+ now: opts.now
16867
+ });
16868
+ if (!parsed.ok) fail(EXIT.VALIDATION, `invalid decision context in ${opts.user}: ${parsed.errors.join("; ")}`);
16869
+ const decisionContext = parsed.context;
16870
+ if (decisionContext.nowIso && !decisionContext.trialInstances) {
16871
+ diag(
16872
+ 'NOTE: the pinned clock only moves trial derivation from trial_instances; an explicit "trial" status is already fixed in time.'
16873
+ );
16247
16874
  }
16248
16875
  if (typeof ctx.customer_id === "string") {
16249
16876
  diag(
@@ -16262,6 +16889,7 @@ program.command("evaluate").description(
16262
16889
  userId,
16263
16890
  planHandle,
16264
16891
  traits,
16892
+ ...decisionContext,
16265
16893
  entitlementHandles: opts.entitlement ? [opts.entitlement] : bulk ? asStringArray2(ctx.entitlement_handles) : [],
16266
16894
  placementIds: bulk ? asStringArray2(ctx.placement_ids) : [],
16267
16895
  slot: wantsSlot ? { slotId: opts.slot, componentType } : void 0
@@ -16271,10 +16899,18 @@ program.command("evaluate").description(
16271
16899
  tenant_id: conn.tenantId,
16272
16900
  user_id: userId,
16273
16901
  ...playbookVersionId ? { playbook_version_id: playbookVersionId } : {},
16274
- evaluated_at: (/* @__PURE__ */ new Date()).toISOString(),
16902
+ // The pinned clock when one was given, so the stamp matches the
16903
+ // instant the decisions were made against.
16904
+ evaluated_at: decisionContext.nowIso ?? (/* @__PURE__ */ new Date()).toISOString(),
16275
16905
  // Provenance: decided in-process by the SDK engine, not by a server.
16276
16906
  evaluation: "local",
16277
- ...result
16907
+ decisions: result.decisions,
16908
+ // Which authored payload the resolver chose, per requested placement.
16909
+ payload_ids: result.payloadIds,
16910
+ entitlements: result.entitlements,
16911
+ placement: result.placement,
16912
+ personalization_tokens: result.personalizationTokens,
16913
+ ...result.trialStatus !== void 0 ? { trial_status: result.trialStatus } : {}
16278
16914
  },
16279
16915
  true
16280
16916
  );
@@ -16546,10 +17182,23 @@ var COMMAND_EXAMPLES = {
16546
17182
  ].join("\n"),
16547
17183
  evaluate: [
16548
17184
  "",
16549
- "Example:",
17185
+ "Examples:",
16550
17186
  " revturbine evaluate --live --user ./ctx.json --entitlement seats",
16551
17187
  " revturbine evaluate --draft --user ./ctx.json --slot upgrade_banner --component-type banner --plan-handle pro",
16552
- ' ctx.json: { "user_id": "u1", "plan_handle": "pro", "entitlement_handles": ["seats"] }'
17188
+ ` revturbine evaluate --live --user ./ctx.json --usage '{"api_calls":{"amount":95}}'`,
17189
+ ` revturbine evaluate --live --user ./ctx.json --trial-status '{"in_trial":true,"trial_limit_type":"time","days_remaining":3}'`,
17190
+ " revturbine evaluate --live --user ./ctx.json --now 2026-09-21T00:00:00Z",
17191
+ "",
17192
+ "ctx.json \u2014 the whole decision context:",
17193
+ ' { "user_id": "u1", "plan_handle": "pro", "traits": { "tier": "smb" },',
17194
+ ' "usage": { "api_calls": { "amount": 95, "limit": 100 } },',
17195
+ ' "trial": { "in_trial": true, "trial_limit_type": "time", "days_remaining": 3 },',
17196
+ ' "trial_instances": [ ... ], "now_iso": "2026-09-21T00:00:00Z",',
17197
+ ' "entitlement_handles": ["seats"], "placement_ids": ["pl_usage_warn"] }',
17198
+ "",
17199
+ ` usage \u2014 omit "limit" and it is taken from the Playbook's entitlement rule.`,
17200
+ " trial \u2014 an explicit status; trial_instances + now_iso derive one at that clock.",
17201
+ " Output carries payload_ids: which authored payload won, per placement."
16553
17202
  ].join("\n")
16554
17203
  };
16555
17204
  for (const [name, text] of Object.entries(COMMAND_EXAMPLES)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.19.2",
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": {
@@ -30,7 +30,7 @@
30
30
  "verify": "npm run typecheck && npm run lint && npm run check:schema && npm run test"
31
31
  },
32
32
  "dependencies": {
33
- "@revturbine/sdk": "^0.7.4",
33
+ "@revturbine/sdk": ">=0.10.9 <1",
34
34
  "commander": "^15.0.0",
35
35
  "semver": "^7.8.5",
36
36
  "zod": "4.4.3"