@nextcommerce/campaigns-os 1.55.0 → 1.59.0

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 (100) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +97 -0
  5. package/campaign-spec/dist/checkout-flow.js +245 -0
  6. package/campaign-spec/dist/index.d.ts +1 -0
  7. package/campaign-spec/dist/index.js +6 -1
  8. package/campaign-spec/dist/normalize.d.ts +3 -2
  9. package/campaign-spec/dist/normalize.js +7 -5
  10. package/campaign-spec/dist/routing.d.ts +6 -3
  11. package/campaign-spec/dist/routing.js +6 -3
  12. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  13. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  14. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  16. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  17. package/campaign-spec/dist/rules/index.d.ts +4 -0
  18. package/campaign-spec/dist/rules/index.js +12 -0
  19. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  21. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  23. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  24. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  25. package/campaign-spec/dist/rules/schema-version.js +10 -5
  26. package/campaign-spec/dist/types.d.ts +20 -3
  27. package/campaign-spec/dist/types.js +3 -2
  28. package/compatibility.json +2 -2
  29. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  30. package/contracts/commerce-surface-catalog.json +26 -27
  31. package/contracts/effects.v1.json +5 -5
  32. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  35. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  45. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  46. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  47. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  48. package/contracts/release-ledger.json +1137 -0
  49. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  50. package/contracts/supported-surface.json +9 -5
  51. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  52. package/docs/build-packet.md +68 -10
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +124 -22
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/package.json +2 -1
  61. package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
  62. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
  63. package/schemas/campaign-spec.v5.schema.json +586 -0
  64. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  65. package/skills/campaign-readback-classification/SKILL.md +3 -3
  66. package/skills/campaign-run-evidence/SKILL.md +3 -3
  67. package/skills/contribution-intake/SKILL.md +3 -3
  68. package/skills/next-campaigns-build/SKILL.md +6 -4
  69. package/skills/next-campaigns-os/SKILL.md +3 -3
  70. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  71. package/skills/next-campaigns-polish/SKILL.md +21 -6
  72. package/skills/next-campaigns-qa/SKILL.md +4 -3
  73. package/skills.json +10 -10
  74. package/src/adapter-decision-contract.mjs +1 -1
  75. package/src/build-brief.mjs +14 -5
  76. package/src/built-site-scope.mjs +91 -0
  77. package/src/cli-helpers.mjs +129 -0
  78. package/src/cli.mjs +117 -57
  79. package/src/commercial-journey.mjs +25 -2
  80. package/src/design-source-package.mjs +1 -1
  81. package/src/design-source-publication.mjs +7 -7
  82. package/src/doctor/checks.mjs +269 -16
  83. package/src/doctor/inspect.mjs +4 -2
  84. package/src/doctor/next-step.mjs +18 -9
  85. package/src/doctor-sidecar.mjs +5 -3
  86. package/src/gate-actions.mjs +21 -1
  87. package/src/input-refresh.mjs +6 -6
  88. package/src/local-preview-policy.mjs +110 -12
  89. package/src/polish-node.mjs +8 -7
  90. package/src/qa-browser.mjs +363 -36
  91. package/src/qa-cart-entry.mjs +183 -14
  92. package/src/qa-node.mjs +38 -34
  93. package/src/qa-test-order-topology.mjs +118 -1
  94. package/src/qa-verdict-discovery.mjs +21 -4
  95. package/src/qa-verdict.mjs +24 -1
  96. package/src/runtime-state-ignore.mjs +7 -5
  97. package/src/sdk-attribute-index.mjs +8 -7
  98. package/src/source-html-intake.mjs +58 -7
  99. package/src/stage-ledger.mjs +4 -2
  100. package/src/stage-record.mjs +203 -36
@@ -76,9 +76,12 @@ export const DECLINE_ROUTE_FIELD = 'on_decline';
76
76
  * is about one field's MEANING: reading a payment-shaped field on a page with
77
77
  * no payment is not respecting the author's intent, it is inventing one.
78
78
  *
79
- * Membership is deliberately narrow. The three-step shop family types its
80
- * `information` / `shipping` / `billing` pages as `checkout`, so they are
81
- * already covered; no certified family carries a second payment-bearing type.
79
+ * Membership is deliberately narrow. From CampaignSpec v5 (campaigns-os#641)
80
+ * `checkout` means exactly the page that takes payment; the earlier steps of a
81
+ * split checkout (the three-step shop family's `information` and `shipping`)
82
+ * are `checkout_step`, which take no payment and advance through `next_page`.
83
+ * A v4 spec that chained `checkout → checkout` is retyped on read
84
+ * (checkout-flow.ts), so the billing page alone stays payment-bearing here.
82
85
  * An `upsell` takes a one-click payment but expresses its post-purchase branch
83
86
  * through `on_accept`, which has its own applicability rule below.
84
87
  * Ratified on campaigns-os#234, 2026-08-25.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CheckoutForwardTarget — a Checkout takes payment, so where it sends the
3
+ * shopper next must be a post-payment page: an upsell, a downsell or a
4
+ * thank-you page (campaigns-os#641).
5
+ *
6
+ * A `checkout` forwarding to another checkout-shaped page, a landing page or a
7
+ * select page means one of two things, both wrong: the page is really a step
8
+ * that collects details and moves on (type it `checkout_step`), or the order
9
+ * would be placed before the shopper reaches the page meant to take payment.
10
+ *
11
+ * Only a target this funnel resolves is judged. An off-graph destination (a
12
+ * rooted path into an existing downstream campaign, an absolute URL) is the
13
+ * partial-scope shape and passes; an unresolved typo is RouteTargetResolves';
14
+ * a Checkout with no forward route at all is CheckoutHasSuccessUrl's.
15
+ */
16
+ import type { Rule } from '../types.ts';
17
+ export declare const CheckoutForwardTarget: Rule;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * CheckoutForwardTarget — a Checkout takes payment, so where it sends the
3
+ * shopper next must be a post-payment page: an upsell, a downsell or a
4
+ * thank-you page (campaigns-os#641).
5
+ *
6
+ * A `checkout` forwarding to another checkout-shaped page, a landing page or a
7
+ * select page means one of two things, both wrong: the page is really a step
8
+ * that collects details and moves on (type it `checkout_step`), or the order
9
+ * would be placed before the shopper reaches the page meant to take payment.
10
+ *
11
+ * Only a target this funnel resolves is judged. An off-graph destination (a
12
+ * rooted path into an existing downstream campaign, an absolute URL) is the
13
+ * partial-scope shape and passes; an unresolved typo is RouteTargetResolves';
14
+ * a Checkout with no forward route at all is CheckoutHasSuccessUrl's.
15
+ */
16
+ import { forwardRouteTarget } from "../routing.js";
17
+ import { POST_PAYMENT_PAGE_TYPES, forwardTargetPage, isPaymentPage } from "../checkout-flow.js";
18
+ export const CheckoutForwardTarget = {
19
+ id: 'CheckoutForwardTarget',
20
+ severity: 'error',
21
+ tags: ['fast', 'structure', 'spec-only'],
22
+ check(spec) {
23
+ const violations = [];
24
+ spec.funnels.forEach((funnel, funnelIdx) => {
25
+ const pages = funnel.pages ?? [];
26
+ pages.forEach((page, pageIdx) => {
27
+ if (!isPaymentPage(page))
28
+ return;
29
+ const target = forwardTargetPage(pages, page);
30
+ if (!target)
31
+ return;
32
+ const targetType = typeof target.type === 'string' ? target.type : '';
33
+ if (POST_PAYMENT_PAGE_TYPES.includes(targetType))
34
+ return;
35
+ violations.push({
36
+ ruleId: 'CheckoutForwardTarget',
37
+ severity: 'error',
38
+ message: `Checkout "${page.id}" forwards to "${target.id}" (type "${targetType || 'none'}"). ` +
39
+ 'A Checkout takes payment, so it must forward to an upsell, downsell or thank-you page. ' +
40
+ 'If this page collects details and moves on without placing an order, type it "checkout_step".',
41
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}`,
42
+ data: { pageId: page.id, target: forwardRouteTarget(page), targetPageId: target.id, targetType },
43
+ });
44
+ });
45
+ });
46
+ return violations;
47
+ },
48
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * CheckoutStepReachesCheckout — every `checkout_step` page must lie on a path
3
+ * to a Checkout. Following its forward links has to arrive at a `checkout`
4
+ * page; a step that dead-ends, loops, leaves the funnel or reaches an offer
5
+ * first collects the shopper's details and never takes payment
6
+ * (campaigns-os#641).
7
+ *
8
+ * Forward links only: a step has no decline branch that could carry the
9
+ * shopper to payment instead.
10
+ */
11
+ import type { Rule } from '../types.ts';
12
+ export declare const CheckoutStepReachesCheckout: Rule;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * CheckoutStepReachesCheckout — every `checkout_step` page must lie on a path
3
+ * to a Checkout. Following its forward links has to arrive at a `checkout`
4
+ * page; a step that dead-ends, loops, leaves the funnel or reaches an offer
5
+ * first collects the shopper's details and never takes payment
6
+ * (campaigns-os#641).
7
+ *
8
+ * Forward links only: a step has no decline branch that could carry the
9
+ * shopper to payment instead.
10
+ */
11
+ import { isCheckoutStepPage, paymentPageFrom } from "../checkout-flow.js";
12
+ export const CheckoutStepReachesCheckout = {
13
+ id: 'CheckoutStepReachesCheckout',
14
+ severity: 'error',
15
+ tags: ['structure', 'spec-only'],
16
+ check(spec) {
17
+ const violations = [];
18
+ spec.funnels.forEach((funnel, funnelIdx) => {
19
+ const pages = funnel.pages ?? [];
20
+ pages.forEach((page, pageIdx) => {
21
+ if (!isCheckoutStepPage(page))
22
+ return;
23
+ if (paymentPageFrom(pages, page))
24
+ return;
25
+ violations.push({
26
+ ruleId: 'CheckoutStepReachesCheckout',
27
+ severity: 'error',
28
+ message: `Checkout step "${page.id}" is not on a path to a Checkout. ` +
29
+ 'Follow its next_page links to the page that takes payment (type "checkout").',
30
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}`,
31
+ data: { pageId: page.id },
32
+ });
33
+ });
34
+ });
35
+ return violations;
36
+ },
37
+ };
@@ -19,6 +19,7 @@
19
19
  *
20
20
  * Message text inherited verbatim from the pre-#110 validator at migration time.
21
21
  */
22
+ import { isCheckoutFlowPage } from "../checkout-flow.js";
22
23
  function isTruthyString(value) {
23
24
  return typeof value === 'string' && value.trim().length > 0;
24
25
  }
@@ -55,7 +56,7 @@ export const ExitIntentValidation = {
55
56
  const refOffer = ref ? index.byRefId.get(String(ref)) : null;
56
57
  const codeOffer = code ? index.byCode.get(String(code).toUpperCase()) : null;
57
58
  // 1. Placement
58
- if (page.type !== 'checkout') {
59
+ if (!isCheckoutFlowPage(page)) {
59
60
  violations.push({
60
61
  ruleId: 'ExitIntentValidation',
61
62
  severity: 'warning',
@@ -31,6 +31,10 @@ export { UpsellHasPackages } from './upsell-has-packages.ts';
31
31
  export { UpsellRoutingComplete } from './upsell-routing-complete.ts';
32
32
  export { RouteFieldIgnoredForPageType } from './route-field-ignored-for-page-type.ts';
33
33
  export { CheckoutHasSuccessUrl } from './checkout-has-success-url.ts';
34
+ export { CheckoutForwardTarget } from './checkout-forward-target.ts';
35
+ export { PrePaymentForwardTarget } from './pre-payment-forward-target.ts';
36
+ export { CheckoutStepReachesCheckout } from './checkout-step-reaches-checkout.ts';
37
+ export { OneCheckoutPerPath } from './one-checkout-per-path.ts';
34
38
  export { UpsellWithoutCheckout } from './upsell-without-checkout.ts';
35
39
  export { DownsellWithoutUpsell } from './downsell-without-upsell.ts';
36
40
  export { PackagePricingSanity } from './package-pricing-sanity.ts';
@@ -30,6 +30,10 @@ import { UpsellRoutingComplete } from "./upsell-routing-complete.js";
30
30
  import { RouteTargetResolves } from "./route-target-resolves.js";
31
31
  import { RouteFieldIgnoredForPageType } from "./route-field-ignored-for-page-type.js";
32
32
  import { CheckoutHasSuccessUrl } from "./checkout-has-success-url.js";
33
+ import { CheckoutForwardTarget } from "./checkout-forward-target.js";
34
+ import { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
35
+ import { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
36
+ import { OneCheckoutPerPath } from "./one-checkout-per-path.js";
33
37
  import { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
34
38
  import { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
35
39
  import { CampaignMetadata } from "./campaign-metadata.js";
@@ -61,6 +65,10 @@ export { UpsellHasPackages } from "./upsell-has-packages.js";
61
65
  export { UpsellRoutingComplete } from "./upsell-routing-complete.js";
62
66
  export { RouteFieldIgnoredForPageType } from "./route-field-ignored-for-page-type.js";
63
67
  export { CheckoutHasSuccessUrl } from "./checkout-has-success-url.js";
68
+ export { CheckoutForwardTarget } from "./checkout-forward-target.js";
69
+ export { PrePaymentForwardTarget } from "./pre-payment-forward-target.js";
70
+ export { CheckoutStepReachesCheckout } from "./checkout-step-reaches-checkout.js";
71
+ export { OneCheckoutPerPath } from "./one-checkout-per-path.js";
64
72
  export { UpsellWithoutCheckout } from "./upsell-without-checkout.js";
65
73
  export { DownsellWithoutUpsell } from "./downsell-without-upsell.js";
66
74
  export { PackagePricingSanity } from "./package-pricing-sanity.js";
@@ -102,6 +110,10 @@ export const allRules = [
102
110
  RouteTargetResolves,
103
111
  RouteFieldIgnoredForPageType,
104
112
  CheckoutHasSuccessUrl,
113
+ CheckoutForwardTarget,
114
+ PrePaymentForwardTarget,
115
+ CheckoutStepReachesCheckout,
116
+ OneCheckoutPerPath,
105
117
  UpsellWithoutCheckout,
106
118
  DownsellWithoutUpsell,
107
119
  PackagePricingSanity,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * OneCheckoutPerPath — every path from an entry page to an upsell or downsell
3
+ * passes exactly one Checkout (campaigns-os#641).
4
+ *
5
+ * None means the shopper reaches a post-purchase offer with no order placed;
6
+ * two means the path takes payment twice. A campaign may hold several
7
+ * Checkouts — split-test paths each reach their own — so the rule counts per
8
+ * path, never per spec.
9
+ *
10
+ * Paths follow both edges a page can traverse (forward and decline, from
11
+ * routing.ts) and start at the funnel's entry pages (checkout-flow.ts
12
+ * `entryPages`): pages flagged `is_entry`, else those nothing routes to. No
13
+ * page order or name is consulted. The search runs, per entry page, over
14
+ * (page, checkouts passed so far: 0, 1, 2+) states, so each bad page is
15
+ * reported once per entry and count however many of that entry's paths reach it.
16
+ *
17
+ * A spec with no Checkout anywhere is left to UpsellWithoutCheckout, so the
18
+ * two never report one defect twice.
19
+ *
20
+ * With build_scope.mode === 'partial' a path that reaches an offer with no
21
+ * Checkout is a warning, as in UpsellWithoutCheckout: a partial upsell build
22
+ * may ship an offer an existing checkout elsewhere routes into.
23
+ */
24
+ import type { Rule } from '../types.ts';
25
+ export declare const OneCheckoutPerPath: Rule;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * OneCheckoutPerPath — every path from an entry page to an upsell or downsell
3
+ * passes exactly one Checkout (campaigns-os#641).
4
+ *
5
+ * None means the shopper reaches a post-purchase offer with no order placed;
6
+ * two means the path takes payment twice. A campaign may hold several
7
+ * Checkouts — split-test paths each reach their own — so the rule counts per
8
+ * path, never per spec.
9
+ *
10
+ * Paths follow both edges a page can traverse (forward and decline, from
11
+ * routing.ts) and start at the funnel's entry pages (checkout-flow.ts
12
+ * `entryPages`): pages flagged `is_entry`, else those nothing routes to. No
13
+ * page order or name is consulted. The search runs, per entry page, over
14
+ * (page, checkouts passed so far: 0, 1, 2+) states, so each bad page is
15
+ * reported once per entry and count however many of that entry's paths reach it.
16
+ *
17
+ * A spec with no Checkout anywhere is left to UpsellWithoutCheckout, so the
18
+ * two never report one defect twice.
19
+ *
20
+ * With build_scope.mode === 'partial' a path that reaches an offer with no
21
+ * Checkout is a warning, as in UpsellWithoutCheckout: a partial upsell build
22
+ * may ship an offer an existing checkout elsewhere routes into.
23
+ */
24
+ import { outgoingEdgeIds } from "../routing.js";
25
+ import { POST_PURCHASE_OFFER_PAGE_TYPES, entryPages, isPaymentPage, resolveRouteTargetPage } from "../checkout-flow.js";
26
+ function isOffer(page) {
27
+ const type = typeof page.type === 'string' ? page.type.trim().toLowerCase() : '';
28
+ return POST_PURCHASE_OFFER_PAGE_TYPES.includes(type);
29
+ }
30
+ export const OneCheckoutPerPath = {
31
+ id: 'OneCheckoutPerPath',
32
+ severity: 'error',
33
+ tags: ['structure', 'spec-only'],
34
+ check(spec) {
35
+ const violations = [];
36
+ const partial = spec.build_scope?.mode === 'partial';
37
+ // A spec with no Checkout at all is UpsellWithoutCheckout's to report; this
38
+ // rule speaks only when Checkouts exist and some path misses or doubles one.
39
+ const anyCheckout = spec.funnels.some((funnel) => (funnel.pages ?? []).some((page) => isPaymentPage(page)));
40
+ spec.funnels.forEach((funnel, funnelIdx) => {
41
+ const pages = (funnel.pages ?? []).filter((page) => page && page.enabled !== false);
42
+ const indexOf = new Map();
43
+ (funnel.pages ?? []).forEach((page, idx) => indexOf.set(page, idx));
44
+ // Searched once per entry page, so every entry whose path misses or
45
+ // doubles the Checkout is named, not only the first one to reach the page.
46
+ for (const entry of entryPages(funnel)) {
47
+ const seen = new Set();
48
+ const reported = new Set();
49
+ const stack = [[entry, 0]];
50
+ while (stack.length) {
51
+ const [page, before] = stack.pop();
52
+ const passed = Math.min(before + (isPaymentPage(page) ? 1 : 0), 2);
53
+ const key = `${indexOf.get(page)}:${passed}`;
54
+ if (seen.has(key))
55
+ continue;
56
+ seen.add(key);
57
+ if (isOffer(page) && passed !== 1 && (passed > 1 || anyCheckout)) {
58
+ const reportKey = `${indexOf.get(page)}:${passed === 0 ? 0 : 2}`;
59
+ if (!reported.has(reportKey)) {
60
+ reported.add(reportKey);
61
+ const none = passed === 0;
62
+ violations.push({
63
+ ruleId: 'OneCheckoutPerPath',
64
+ severity: none && partial ? 'warning' : 'error',
65
+ message: none
66
+ ? `A path from entry page "${entry.id}" reaches "${page.id}" (${page.type}) without passing a Checkout, so no order has been placed.` +
67
+ (partial ? ' Valid for partial upsell builds when an existing checkout routes into this page.' : '')
68
+ : `A path from entry page "${entry.id}" reaches "${page.id}" (${page.type}) after passing more than one Checkout, so it takes payment twice.`,
69
+ path: `/funnels/${funnelIdx}/pages/${indexOf.get(page)}`,
70
+ data: { pageId: page.id, entryPageId: entry.id, checkoutsPassed: none ? 0 : 2, isPartialScope: partial },
71
+ });
72
+ }
73
+ }
74
+ for (const target of outgoingEdgeIds(page)) {
75
+ const next = resolveRouteTargetPage(pages, target);
76
+ if (next)
77
+ stack.push([next, passed]);
78
+ }
79
+ }
80
+ }
81
+ });
82
+ return violations;
83
+ },
84
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * PrePaymentForwardTarget — a `select` or `checkout_step` page takes no
3
+ * payment, so it must not forward straight to a post-payment page (an upsell,
4
+ * a downsell or a thank-you page). Doing so skips the Checkout: the shopper
5
+ * reaches an offer or a receipt with no order placed (campaigns-os#641).
6
+ *
7
+ * Only the resolved forward link is judged — routing.ts already ignores a
8
+ * `success_url` or `on_accept` on these types, and RouteFieldIgnoredForPageType
9
+ * warns about those separately.
10
+ */
11
+ import type { Rule } from '../types.ts';
12
+ export declare const PrePaymentForwardTarget: Rule;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * PrePaymentForwardTarget — a `select` or `checkout_step` page takes no
3
+ * payment, so it must not forward straight to a post-payment page (an upsell,
4
+ * a downsell or a thank-you page). Doing so skips the Checkout: the shopper
5
+ * reaches an offer or a receipt with no order placed (campaigns-os#641).
6
+ *
7
+ * Only the resolved forward link is judged — routing.ts already ignores a
8
+ * `success_url` or `on_accept` on these types, and RouteFieldIgnoredForPageType
9
+ * warns about those separately.
10
+ */
11
+ import { forwardRouteTarget } from "../routing.js";
12
+ import { POST_PAYMENT_PAGE_TYPES, forwardTargetPage, isPrePaymentPage } from "../checkout-flow.js";
13
+ export const PrePaymentForwardTarget = {
14
+ id: 'PrePaymentForwardTarget',
15
+ severity: 'error',
16
+ tags: ['fast', 'structure', 'spec-only'],
17
+ check(spec) {
18
+ const violations = [];
19
+ spec.funnels.forEach((funnel, funnelIdx) => {
20
+ const pages = funnel.pages ?? [];
21
+ pages.forEach((page, pageIdx) => {
22
+ if (!isPrePaymentPage(page))
23
+ return;
24
+ const target = forwardTargetPage(pages, page);
25
+ if (!target)
26
+ return;
27
+ const targetType = typeof target.type === 'string' ? target.type : '';
28
+ if (!POST_PAYMENT_PAGE_TYPES.includes(targetType))
29
+ return;
30
+ violations.push({
31
+ ruleId: 'PrePaymentForwardTarget',
32
+ severity: 'error',
33
+ message: `"${page.type}" page "${page.id}" forwards to "${target.id}" (type "${targetType}") without passing a Checkout. ` +
34
+ 'Route it (via next_page) to the next checkout step or to the Checkout that takes payment.',
35
+ path: `/funnels/${funnelIdx}/pages/${pageIdx}/next_page`,
36
+ data: { pageId: page.id, pageType: page.type, target: forwardRouteTarget(page), targetPageId: target.id, targetType },
37
+ });
38
+ });
39
+ });
40
+ return violations;
41
+ },
42
+ };
@@ -4,6 +4,7 @@
4
4
  * intent because both surfaces apply a voucher/code-backed Offer through the
5
5
  * same Campaign Cart SDK/Campaigns API pricing path.
6
6
  */
7
+ import { isCheckoutFlowPage } from "../checkout-flow.js";
7
8
  function isTruthyString(value) {
8
9
  return typeof value === 'string' && value.trim().length > 0;
9
10
  }
@@ -39,7 +40,7 @@ export const PromoCodeInputValidation = {
39
40
  const code = promo.offer_code;
40
41
  const refOffer = ref ? index.byRefId.get(String(ref)) : null;
41
42
  const codeOffer = code ? index.byCode.get(String(code).toUpperCase()) : null;
42
- if (page.type !== 'checkout') {
43
+ if (!isCheckoutFlowPage(page)) {
43
44
  violations.push({
44
45
  ruleId: 'PromoCodeInputValidation',
45
46
  severity: 'warning',
@@ -9,13 +9,18 @@
9
9
  * lineage errors here at authoring/export time instead of surfacing as
10
10
  * downstream shape drift.
11
11
  *
12
- * SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version enum in
13
- * schemas/campaign-spec.v4.schema.json (and compatibility.json's campaign_spec
14
- * range). The rule stays pure (no filesystem), so the constant duplicates the
12
+ * SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version enums of
13
+ * schemas/campaign-spec.v4.schema.json and schemas/campaign-spec.v5.schema.json,
14
+ * concatenated (and compatibility.json's campaign_spec range). The rule stays pure (no filesystem), so the constant duplicates the
15
15
  * schema enum — the sync test in ../test/rules/schema-version.test.ts is the
16
16
  * drift gate, following the UnknownTopLevelFields pattern.
17
17
  */
18
18
  import type { Rule } from '../types.ts';
19
- /** Supported CampaignSpec lineage versions — keep in sync with the v4 JSON Schema enum. */
19
+ /**
20
+ * Supported CampaignSpec lineage versions — the v4 JSON Schema enum followed by
21
+ * the v5 one, in that order. A 4.2/4.3 spec is upgraded to v5 on read
22
+ * (checkout-flow.ts), so this rule sees `5.0` for it after normalize(); the v4
23
+ * entries stay so a caller running the rule on an un-normalized spec agrees.
24
+ */
20
25
  export declare const SUPPORTED_SCHEMA_VERSIONS: readonly string[];
21
26
  export declare const SchemaVersion: Rule;
@@ -9,14 +9,19 @@
9
9
  * lineage errors here at authoring/export time instead of surfacing as
10
10
  * downstream shape drift.
11
11
  *
12
- * SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version enum in
13
- * schemas/campaign-spec.v4.schema.json (and compatibility.json's campaign_spec
14
- * range). The rule stays pure (no filesystem), so the constant duplicates the
12
+ * SUPPORTED_SCHEMA_VERSIONS must stay identical to the schema_version enums of
13
+ * schemas/campaign-spec.v4.schema.json and schemas/campaign-spec.v5.schema.json,
14
+ * concatenated (and compatibility.json's campaign_spec range). The rule stays pure (no filesystem), so the constant duplicates the
15
15
  * schema enum — the sync test in ../test/rules/schema-version.test.ts is the
16
16
  * drift gate, following the UnknownTopLevelFields pattern.
17
17
  */
18
- /** Supported CampaignSpec lineage versions — keep in sync with the v4 JSON Schema enum. */
19
- export const SUPPORTED_SCHEMA_VERSIONS = Object.freeze(['4.2', '4.3']);
18
+ /**
19
+ * Supported CampaignSpec lineage versions — the v4 JSON Schema enum followed by
20
+ * the v5 one, in that order. A 4.2/4.3 spec is upgraded to v5 on read
21
+ * (checkout-flow.ts), so this rule sees `5.0` for it after normalize(); the v4
22
+ * entries stay so a caller running the rule on an un-normalized spec agrees.
23
+ */
24
+ export const SUPPORTED_SCHEMA_VERSIONS = Object.freeze(['4.2', '4.3', '5.0']);
20
25
  export const SchemaVersion = {
21
26
  id: 'SchemaVersion',
22
27
  severity: 'error',
@@ -2,8 +2,9 @@
2
2
  * Type surface for the CampaignSpec contract layer.
3
3
  *
4
4
  * The CampaignSpec is the central authoring contract of this repo (see
5
- * ../CONTEXT.md). v4.3 is the authoring shape; v4.2 funnels[] is the canonical
6
- * internal shape that rules operate on (after normalize()).
5
+ * ../CONTEXT.md). v5.0 is the authoring shape; funnels[] is the canonical
6
+ * internal shape that rules operate on (after normalize(), which upgrades a
7
+ * v4.2/v4.3 spec to the v5 page vocabulary — see checkout-flow.ts).
7
8
  *
8
9
  * Types here are intentionally permissive on optional fields — the rule
9
10
  * registry catches missing/malformed fields, rather than the type system
@@ -63,7 +64,23 @@ export type PageType = 'presell' | 'landing'
63
64
  * Not to be confused with the page ID "select", which several family
64
65
  * contracts use for this page and which is a separate namespace.
65
66
  */
66
- | 'select' | 'checkout' | 'upsell' | 'downsell' | 'thankyou';
67
+ | 'select'
68
+ /**
69
+ * A page with a checkout form that collects details and moves on without
70
+ * placing an order: the information and shipping steps of a split checkout
71
+ * (campaigns-os#641). Routes forward through `next_page`; it takes no
72
+ * payment, so `success_url` and `on_accept` are ignored here as on `select`.
73
+ * Renders as SDK page type `checkout`; its form carries
74
+ * `data-next-checkout-step` and `data-next-step-number`.
75
+ */
76
+ | 'checkout_step'
77
+ /**
78
+ * The page that takes payment. Exactly one on every path from an entry page
79
+ * to an upsell. Routes forward through `success_url` (or `next_page`) to an
80
+ * upsell, downsell or thank-you page. A campaign may hold several, one per
81
+ * path.
82
+ */
83
+ | 'checkout' | 'upsell' | 'downsell' | 'thankyou';
67
84
  /**
68
85
  * Offer condition/benefit blocks, as exported by the Map Builder. Both share
69
86
  * the { type, value, description } shape (e.g. condition
@@ -2,8 +2,9 @@
2
2
  * Type surface for the CampaignSpec contract layer.
3
3
  *
4
4
  * The CampaignSpec is the central authoring contract of this repo (see
5
- * ../CONTEXT.md). v4.3 is the authoring shape; v4.2 funnels[] is the canonical
6
- * internal shape that rules operate on (after normalize()).
5
+ * ../CONTEXT.md). v5.0 is the authoring shape; funnels[] is the canonical
6
+ * internal shape that rules operate on (after normalize(), which upgrades a
7
+ * v4.2/v4.3 spec to the v5 page vocabulary — see checkout-flow.ts).
7
8
  *
8
9
  * Types here are intentionally permissive on optional fields — the rule
9
10
  * registry catches missing/malformed fields, rather than the type system
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "package": "@nextcommerce/campaigns-os",
3
- "version": "1.55.0",
3
+ "version": "1.59.0",
4
4
  "status": "developer-preview",
5
5
  "contracts": {
6
- "campaign_spec": "4.2-4.3",
6
+ "campaign_spec": "4.2-5.0",
7
7
  "build_packet": "campaign-runtime-build-packet/v0",
8
8
  "build_context": "campaign-runtime-build-context/v0",
9
9
  "assembly_report": "campaign-runtime-assembly-report/v0",
@@ -2,8 +2,8 @@
2
2
  "schema_version": "campaign-cart-sdk-support-policy/v0",
3
3
  "provenance": {
4
4
  "source": "NextCommerceCo/campaign-cart release tags",
5
- "latest_known_release": "0.4.41",
6
- "captured_at": "2026-10-07"
5
+ "latest_known_release": "0.4.42",
6
+ "captured_at": "2026-10-09"
7
7
  },
8
8
  "source": "contracts/campaign-cart-sdk-support-policy.v0.json",
9
9
  "minimum_supported": "0.4.20",
@@ -848,9 +848,9 @@
848
848
  }
849
849
  },
850
850
  "verification": {
851
- "sdk_version": "0.4.41",
852
- "verified_at": "2026-10-06T23:03:41Z",
853
- "evidence": "sdk-0.4.41-2026-10-06.5",
851
+ "sdk_version": "0.4.42",
852
+ "verified_at": "2026-10-09T01:35:51Z",
853
+ "evidence": "sdk-0.4.42-2026-10-09",
854
854
  "status": "certified",
855
855
  "source": "template-verification.json"
856
856
  }
@@ -1272,9 +1272,9 @@
1272
1272
  }
1273
1273
  },
1274
1274
  "verification": {
1275
- "sdk_version": "0.4.41",
1276
- "verified_at": "2026-10-06T23:03:41Z",
1277
- "evidence": "sdk-0.4.41-2026-10-06.5",
1275
+ "sdk_version": "0.4.42",
1276
+ "verified_at": "2026-10-09T01:35:51Z",
1277
+ "evidence": "sdk-0.4.42-2026-10-09",
1278
1278
  "status": "certified",
1279
1279
  "source": "template-verification.json"
1280
1280
  }
@@ -1583,9 +1583,9 @@
1583
1583
  }
1584
1584
  },
1585
1585
  "verification": {
1586
- "sdk_version": "0.4.41",
1587
- "verified_at": "2026-10-06T23:03:41Z",
1588
- "evidence": "sdk-0.4.41-2026-10-06.5",
1586
+ "sdk_version": "0.4.42",
1587
+ "verified_at": "2026-10-09T01:35:51Z",
1588
+ "evidence": "sdk-0.4.42-2026-10-09",
1589
1589
  "status": "certified",
1590
1590
  "source": "template-verification.json"
1591
1591
  }
@@ -1886,9 +1886,9 @@
1886
1886
  }
1887
1887
  },
1888
1888
  "verification": {
1889
- "sdk_version": "0.4.41",
1890
- "verified_at": "2026-10-06T23:03:41Z",
1891
- "evidence": "sdk-0.4.41-2026-10-06.5",
1889
+ "sdk_version": "0.4.42",
1890
+ "verified_at": "2026-10-09T01:35:51Z",
1891
+ "evidence": "sdk-0.4.42-2026-10-09",
1892
1892
  "status": "certified",
1893
1893
  "source": "template-verification.json"
1894
1894
  }
@@ -2227,9 +2227,9 @@
2227
2227
  }
2228
2228
  },
2229
2229
  "verification": {
2230
- "sdk_version": "0.4.41",
2231
- "verified_at": "2026-10-06T23:03:41Z",
2232
- "evidence": "sdk-0.4.41-2026-10-06.5",
2230
+ "sdk_version": "0.4.42",
2231
+ "verified_at": "2026-10-09T01:35:51Z",
2232
+ "evidence": "sdk-0.4.42-2026-10-09",
2233
2233
  "status": "certified",
2234
2234
  "source": "template-verification.json"
2235
2235
  }
@@ -2666,9 +2666,9 @@
2666
2666
  }
2667
2667
  },
2668
2668
  "verification": {
2669
- "sdk_version": "0.4.41",
2670
- "verified_at": "2026-10-06T23:03:41Z",
2671
- "evidence": "sdk-0.4.41-2026-10-06.5",
2669
+ "sdk_version": "0.4.42",
2670
+ "verified_at": "2026-10-09T01:35:51Z",
2671
+ "evidence": "sdk-0.4.42-2026-10-09",
2672
2672
  "status": "certified",
2673
2673
  "source": "template-verification.json"
2674
2674
  }
@@ -2686,7 +2686,6 @@
2686
2686
  ],
2687
2687
  "canonicalSurfaces": {
2688
2688
  "selectStep": "mv-selection-step",
2689
- "checkoutSelector": "mv-selected-bundle-review",
2690
2689
  "variantSlots": "bundle-slot-selector",
2691
2690
  "personalization": "per-slot picker (data-next-property per slot, toggled by slot_personalization); order-wide default via personalization-field (ships unwired)",
2692
2691
  "orderSummary": "cart-summary03|cart-summary04",
@@ -3023,9 +3022,9 @@
3023
3022
  }
3024
3023
  },
3025
3024
  "verification": {
3026
- "sdk_version": "0.4.41",
3027
- "verified_at": "2026-10-06T23:03:41Z",
3028
- "evidence": "sdk-0.4.41-2026-10-06.5",
3025
+ "sdk_version": "0.4.42",
3026
+ "verified_at": "2026-10-09T01:35:51Z",
3027
+ "evidence": "sdk-0.4.42-2026-10-09",
3029
3028
  "status": "certified",
3030
3029
  "source": "template-verification.json"
3031
3030
  }
@@ -3313,9 +3312,9 @@
3313
3312
  }
3314
3313
  },
3315
3314
  "verification": {
3316
- "sdk_version": "0.4.41",
3317
- "verified_at": "2026-10-06T23:03:41Z",
3318
- "evidence": "sdk-0.4.41-2026-10-06.5",
3315
+ "sdk_version": "0.4.42",
3316
+ "verified_at": "2026-10-09T01:35:51Z",
3317
+ "evidence": "sdk-0.4.42-2026-10-09",
3319
3318
  "status": "certified",
3320
3319
  "source": "template-verification.json"
3321
3320
  }
@@ -3504,6 +3503,6 @@
3504
3503
  }
3505
3504
  },
3506
3505
  "_synced_from_repo": "NextCommerceCo/campaign-cart-starter-templates",
3507
- "_synced_from_ref": "783329017c6994a94d11e46580c33bd052aabdb0",
3508
- "_synced_from_sha": "783329017c6994a94d11e46580c33bd052aabdb0"
3506
+ "_synced_from_ref": "main",
3507
+ "_synced_from_sha": "d5ff501ab57aef160a7108341de8a952006a28a4"
3509
3508
  }