@nextcommerce/campaigns-os 1.55.0 → 1.57.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 (93) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONTEXT.md +11 -0
  3. package/campaign-spec/README.md +15 -1
  4. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  5. package/campaign-spec/dist/checkout-flow.js +229 -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/fixtures/runtime-recipe/accept/current.json +6 -1
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  43. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  45. package/contracts/release-ledger.json +705 -0
  46. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  47. package/contracts/supported-surface.json +8 -4
  48. package/docs/build-packet.md +52 -8
  49. package/docs/local-setup.md +1 -1
  50. package/docs/orientation-contract-reference.md +1 -1
  51. package/docs/qa-and-test-orders.md +78 -18
  52. package/docs/runtime-readiness.md +8 -3
  53. package/docs/skills-revision.md +10 -10
  54. package/docs/supported-surface.md +1 -1
  55. package/docs/versioning.md +4 -1
  56. package/package.json +2 -1
  57. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  58. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  59. package/schemas/campaign-spec.v5.schema.json +586 -0
  60. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  61. package/skills/campaign-readback-classification/SKILL.md +3 -3
  62. package/skills/campaign-run-evidence/SKILL.md +3 -3
  63. package/skills/contribution-intake/SKILL.md +3 -3
  64. package/skills/next-campaigns-build/SKILL.md +6 -4
  65. package/skills/next-campaigns-os/SKILL.md +3 -3
  66. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  67. package/skills/next-campaigns-polish/SKILL.md +21 -6
  68. package/skills/next-campaigns-qa/SKILL.md +4 -3
  69. package/skills.json +10 -10
  70. package/src/adapter-decision-contract.mjs +1 -1
  71. package/src/build-brief.mjs +14 -5
  72. package/src/cli-helpers.mjs +129 -0
  73. package/src/cli.mjs +121 -53
  74. package/src/commercial-journey.mjs +25 -2
  75. package/src/design-source-package.mjs +1 -1
  76. package/src/design-source-publication.mjs +7 -7
  77. package/src/doctor/checks.mjs +196 -13
  78. package/src/doctor/inspect.mjs +4 -2
  79. package/src/doctor/next-step.mjs +31 -11
  80. package/src/doctor-sidecar.mjs +5 -3
  81. package/src/gate-actions.mjs +21 -1
  82. package/src/input-refresh.mjs +6 -6
  83. package/src/local-preview-policy.mjs +110 -12
  84. package/src/polish-node.mjs +3 -3
  85. package/src/qa-browser.mjs +226 -34
  86. package/src/qa-cart-entry.mjs +78 -14
  87. package/src/qa-node.mjs +35 -33
  88. package/src/qa-test-order-topology.mjs +118 -1
  89. package/src/qa-verdict-discovery.mjs +21 -4
  90. package/src/runtime-state-ignore.mjs +7 -5
  91. package/src/source-html-intake.mjs +46 -4
  92. package/src/stage-ledger.mjs +4 -2
  93. package/src/stage-record.mjs +3 -3
@@ -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.57.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",
@@ -3,7 +3,7 @@
3
3
  "_growth_note": "When a bound below is genuinely reached, the answer is to find out why before raising it. A dependency install that exceeds its bound on a warm machine is a supply-chain change, not a slow morning; an output inventory that exceeds its file or byte bound is a build that went wrong, not a package that grew 50x overnight. Raising a bound is the fallback, it advances recipe_revision, and it owes a release-ledger entry. Widening the accepted npm range follows the same path, and its trigger is external and checkable: widen when a Node release line ships that npm major by default, not when a particular machine happens to have it installed.",
4
4
  "schema": "campaigns-os-runtime-recipe/v1",
5
5
  "recipe_kind": "campaigns-os-node-v1",
6
- "recipe_revision": "1.0.2",
6
+ "recipe_revision": "1.0.3",
7
7
  "refusal_reason_code": "runtime_recipe_refused",
8
8
  "fail_closed": true,
9
9
  "unperformable_check_disposition": "failed",
@@ -106,6 +106,7 @@
106
106
  "fingerprint_algorithm": "sha256",
107
107
  "files": [
108
108
  "campaign-spec/analytics-vocabulary.ts",
109
+ "campaign-spec/checkout-flow.ts",
109
110
  "campaign-spec/index.ts",
110
111
  "campaign-spec/normalize.ts",
111
112
  "campaign-spec/package.json",
@@ -113,7 +114,9 @@
113
114
  "campaign-spec/rules/analytics-contract-shape.ts",
114
115
  "campaign-spec/rules/assembly-hints-shape.ts",
115
116
  "campaign-spec/rules/campaign-metadata.ts",
117
+ "campaign-spec/rules/checkout-forward-target.ts",
116
118
  "campaign-spec/rules/checkout-has-success-url.ts",
119
+ "campaign-spec/rules/checkout-step-reaches-checkout.ts",
117
120
  "campaign-spec/rules/cycle-detection.ts",
118
121
  "campaign-spec/rules/design-source-shape.ts",
119
122
  "campaign-spec/rules/downsell-without-upsell.ts",
@@ -124,9 +127,11 @@
124
127
  "campaign-spec/rules/funnel-weight-sum.ts",
125
128
  "campaign-spec/rules/index.ts",
126
129
  "campaign-spec/rules/offer-ref-integrity.ts",
130
+ "campaign-spec/rules/one-checkout-per-path.ts",
127
131
  "campaign-spec/rules/package-pricing-sanity.ts",
128
132
  "campaign-spec/rules/page-count.ts",
129
133
  "campaign-spec/rules/page-id-uniqueness.ts",
134
+ "campaign-spec/rules/pre-payment-forward-target.ts",
130
135
  "campaign-spec/rules/promo-code-input-validation.ts",
131
136
  "campaign-spec/rules/promo-codes-shape.ts",
132
137
  "campaign-spec/rules/route-field-ignored-for-page-type.ts",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "campaigns-os-runtime-recipe/v1",
3
3
  "recipe_kind": "campaigns-os-node-v1",
4
- "recipe_revision": "1.0.2",
4
+ "recipe_revision": "1.0.3",
5
5
  "refusal_reason_code": "runtime_recipe_refused",
6
6
  "fail_closed": true,
7
7
  "unperformable_check_disposition": "failed",
@@ -101,6 +101,7 @@
101
101
  "fingerprint_algorithm": "sha256",
102
102
  "files": [
103
103
  "campaign-spec/analytics-vocabulary.ts",
104
+ "campaign-spec/checkout-flow.ts",
104
105
  "campaign-spec/index.ts",
105
106
  "campaign-spec/normalize.ts",
106
107
  "campaign-spec/package.json",
@@ -108,7 +109,9 @@
108
109
  "campaign-spec/rules/analytics-contract-shape.ts",
109
110
  "campaign-spec/rules/assembly-hints-shape.ts",
110
111
  "campaign-spec/rules/campaign-metadata.ts",
112
+ "campaign-spec/rules/checkout-forward-target.ts",
111
113
  "campaign-spec/rules/checkout-has-success-url.ts",
114
+ "campaign-spec/rules/checkout-step-reaches-checkout.ts",
112
115
  "campaign-spec/rules/cycle-detection.ts",
113
116
  "campaign-spec/rules/design-source-shape.ts",
114
117
  "campaign-spec/rules/downsell-without-upsell.ts",
@@ -119,9 +122,11 @@
119
122
  "campaign-spec/rules/funnel-weight-sum.ts",
120
123
  "campaign-spec/rules/index.ts",
121
124
  "campaign-spec/rules/offer-ref-integrity.ts",
125
+ "campaign-spec/rules/one-checkout-per-path.ts",
122
126
  "campaign-spec/rules/package-pricing-sanity.ts",
123
127
  "campaign-spec/rules/page-count.ts",
124
128
  "campaign-spec/rules/page-id-uniqueness.ts",
129
+ "campaign-spec/rules/pre-payment-forward-target.ts",
125
130
  "campaign-spec/rules/promo-code-input-validation.ts",
126
131
  "campaign-spec/rules/promo-codes-shape.ts",
127
132
  "campaign-spec/rules/route-field-ignored-for-page-type.ts",
@@ -2,7 +2,7 @@
2
2
  "_note": "Declarative descriptions of the prepared-runtime states an output-check implementation must distinguish. A test builds each state from the accepted recipe rather than from paths written down here, so adding a module to the input set cannot leave a fixture describing an inventory that no longer exists.",
3
3
  "schema": "campaigns-os-runtime-recipe-dist-states/v1",
4
4
  "directory": "campaign-spec/dist",
5
- "expected_file_count": 76,
5
+ "expected_file_count": 86,
6
6
  "states": [
7
7
  {
8
8
  "id": "healthy",
@@ -3,7 +3,7 @@
3
3
  "_growth_note": "When a bound below is genuinely reached, the answer is to find out why before raising it. A dependency install that exceeds its bound on a warm machine is a supply-chain change, not a slow morning; an output inventory that exceeds its file or byte bound is a build that went wrong, not a package that grew 50x overnight. Raising a bound is the fallback, it advances recipe_revision, and it owes a release-ledger entry. Widening the accepted npm range follows the same path, and its trigger is external and checkable: widen when a Node release line ships that npm major by default, not when a particular machine happens to have it installed.",
4
4
  "schema": "campaigns-os-runtime-recipe/v1",
5
5
  "recipe_kind": "campaigns-os-node-v1",
6
- "recipe_revision": "1.0.2",
6
+ "recipe_revision": "1.0.3",
7
7
  "refusal_reason_code": "runtime_recipe_refused",
8
8
  "fail_closed": false,
9
9
  "unperformable_check_disposition": "failed",
@@ -106,6 +106,7 @@
106
106
  "fingerprint_algorithm": "sha256",
107
107
  "files": [
108
108
  "campaign-spec/analytics-vocabulary.ts",
109
+ "campaign-spec/checkout-flow.ts",
109
110
  "campaign-spec/index.ts",
110
111
  "campaign-spec/normalize.ts",
111
112
  "campaign-spec/package.json",
@@ -113,7 +114,9 @@
113
114
  "campaign-spec/rules/analytics-contract-shape.ts",
114
115
  "campaign-spec/rules/assembly-hints-shape.ts",
115
116
  "campaign-spec/rules/campaign-metadata.ts",
117
+ "campaign-spec/rules/checkout-forward-target.ts",
116
118
  "campaign-spec/rules/checkout-has-success-url.ts",
119
+ "campaign-spec/rules/checkout-step-reaches-checkout.ts",
117
120
  "campaign-spec/rules/cycle-detection.ts",
118
121
  "campaign-spec/rules/design-source-shape.ts",
119
122
  "campaign-spec/rules/downsell-without-upsell.ts",
@@ -124,9 +127,11 @@
124
127
  "campaign-spec/rules/funnel-weight-sum.ts",
125
128
  "campaign-spec/rules/index.ts",
126
129
  "campaign-spec/rules/offer-ref-integrity.ts",
130
+ "campaign-spec/rules/one-checkout-per-path.ts",
127
131
  "campaign-spec/rules/package-pricing-sanity.ts",
128
132
  "campaign-spec/rules/page-count.ts",
129
133
  "campaign-spec/rules/page-id-uniqueness.ts",
134
+ "campaign-spec/rules/pre-payment-forward-target.ts",
130
135
  "campaign-spec/rules/promo-code-input-validation.ts",
131
136
  "campaign-spec/rules/promo-codes-shape.ts",
132
137
  "campaign-spec/rules/route-field-ignored-for-page-type.ts",
@@ -3,7 +3,7 @@
3
3
  "_growth_note": "When a bound below is genuinely reached, the answer is to find out why before raising it. A dependency install that exceeds its bound on a warm machine is a supply-chain change, not a slow morning; an output inventory that exceeds its file or byte bound is a build that went wrong, not a package that grew 50x overnight. Raising a bound is the fallback, it advances recipe_revision, and it owes a release-ledger entry. Widening the accepted npm range follows the same path, and its trigger is external and checkable: widen when a Node release line ships that npm major by default, not when a particular machine happens to have it installed.",
4
4
  "schema": "campaigns-os-runtime-recipe/v1",
5
5
  "recipe_kind": "campaigns-os-node-v1",
6
- "recipe_revision": "1.0.2",
6
+ "recipe_revision": "1.0.3",
7
7
  "refusal_reason_code": "runtime_recipe_refused",
8
8
  "fail_closed": true,
9
9
  "unperformable_check_disposition": "failed",
@@ -104,6 +104,7 @@
104
104
  "fingerprint_algorithm": "sha256",
105
105
  "files": [
106
106
  "campaign-spec/analytics-vocabulary.ts",
107
+ "campaign-spec/checkout-flow.ts",
107
108
  "campaign-spec/index.ts",
108
109
  "campaign-spec/normalize.ts",
109
110
  "campaign-spec/package.json",
@@ -111,7 +112,9 @@
111
112
  "campaign-spec/rules/analytics-contract-shape.ts",
112
113
  "campaign-spec/rules/assembly-hints-shape.ts",
113
114
  "campaign-spec/rules/campaign-metadata.ts",
115
+ "campaign-spec/rules/checkout-forward-target.ts",
114
116
  "campaign-spec/rules/checkout-has-success-url.ts",
117
+ "campaign-spec/rules/checkout-step-reaches-checkout.ts",
115
118
  "campaign-spec/rules/cycle-detection.ts",
116
119
  "campaign-spec/rules/design-source-shape.ts",
117
120
  "campaign-spec/rules/downsell-without-upsell.ts",
@@ -122,9 +125,11 @@
122
125
  "campaign-spec/rules/funnel-weight-sum.ts",
123
126
  "campaign-spec/rules/index.ts",
124
127
  "campaign-spec/rules/offer-ref-integrity.ts",
128
+ "campaign-spec/rules/one-checkout-per-path.ts",
125
129
  "campaign-spec/rules/package-pricing-sanity.ts",
126
130
  "campaign-spec/rules/page-count.ts",
127
131
  "campaign-spec/rules/page-id-uniqueness.ts",
132
+ "campaign-spec/rules/pre-payment-forward-target.ts",
128
133
  "campaign-spec/rules/promo-code-input-validation.ts",
129
134
  "campaign-spec/rules/promo-codes-shape.ts",
130
135
  "campaign-spec/rules/route-field-ignored-for-page-type.ts",
@@ -3,7 +3,7 @@
3
3
  "_growth_note": "When a bound below is genuinely reached, the answer is to find out why before raising it. A dependency install that exceeds its bound on a warm machine is a supply-chain change, not a slow morning; an output inventory that exceeds its file or byte bound is a build that went wrong, not a package that grew 50x overnight. Raising a bound is the fallback, it advances recipe_revision, and it owes a release-ledger entry. Widening the accepted npm range follows the same path, and its trigger is external and checkable: widen when a Node release line ships that npm major by default, not when a particular machine happens to have it installed.",
4
4
  "schema": "campaigns-os-runtime-recipe/v1",
5
5
  "recipe_kind": "campaigns-os-node-v1",
6
- "recipe_revision": "1.0.2",
6
+ "recipe_revision": "1.0.3",
7
7
  "refusal_reason_code": "runtime_recipe_refused",
8
8
  "fail_closed": true,
9
9
  "unperformable_check_disposition": "failed",
@@ -106,6 +106,7 @@
106
106
  "fingerprint_algorithm": "sha256",
107
107
  "files": [
108
108
  "campaign-spec/analytics-vocabulary.ts",
109
+ "campaign-spec/checkout-flow.ts",
109
110
  "campaign-spec/index.ts",
110
111
  "campaign-spec/normalize.ts",
111
112
  "campaign-spec/package.json",
@@ -113,7 +114,9 @@
113
114
  "campaign-spec/rules/analytics-contract-shape.ts",
114
115
  "campaign-spec/rules/assembly-hints-shape.ts",
115
116
  "campaign-spec/rules/campaign-metadata.ts",
117
+ "campaign-spec/rules/checkout-forward-target.ts",
116
118
  "campaign-spec/rules/checkout-has-success-url.ts",
119
+ "campaign-spec/rules/checkout-step-reaches-checkout.ts",
117
120
  "campaign-spec/rules/cycle-detection.ts",
118
121
  "campaign-spec/rules/design-source-shape.ts",
119
122
  "campaign-spec/rules/downsell-without-upsell.ts",
@@ -124,9 +127,11 @@
124
127
  "campaign-spec/rules/funnel-weight-sum.ts",
125
128
  "campaign-spec/rules/index.ts",
126
129
  "campaign-spec/rules/offer-ref-integrity.ts",
130
+ "campaign-spec/rules/one-checkout-per-path.ts",
127
131
  "campaign-spec/rules/package-pricing-sanity.ts",
128
132
  "campaign-spec/rules/page-count.ts",
129
133
  "campaign-spec/rules/page-id-uniqueness.ts",
134
+ "campaign-spec/rules/pre-payment-forward-target.ts",
130
135
  "campaign-spec/rules/promo-code-input-validation.ts",
131
136
  "campaign-spec/rules/promo-codes-shape.ts",
132
137
  "campaign-spec/rules/route-field-ignored-for-page-type.ts",