@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,130 @@
2
2
 
3
3
  Notable supported-surface changes are recorded here.
4
4
 
5
+ ## [1.59.0] - 2026-10-09
6
+
7
+ ### Changed
8
+
9
+ - Publishes the untagged `1.58.0` change: multi-step checkout frontmatter follows the starter-template `next_step` contract (campaigns-os#641).
10
+ - Publishes `1.58.0+agent.2`: doctor warns `template_contract.select_step_missing` for a two-step family with no select page before checkout, and the phone scan skips comments and `<style>` content (campaigns-os#658, campaigns-os#660).
11
+ - Publishes `1.58.0+agent.3`: a stale build fingerprint names the extra, missing and changed files and flags sync conflict copies (campaigns-os#659).
12
+ - Publishes `1.58.0+agent.4`: QA verdicts separate planned order paths from retry attempts (campaigns-os#661).
13
+ - Publishes `1.58.0+agent.6`: QA cart entry chooses each slot's variant on a select page before Next (campaigns-os#667).
14
+ - Publishes `1.58.0+agent.7`: the starter catalog is re-synced to campaign-cart-starter-templates `d5ff501` (campaign-cart-starter-templates#223, SDK 0.4.42).
15
+ - Bundled skills advance to `1.59.0+skills.1`.
16
+
17
+ ### Added
18
+
19
+ `record deploy` records a hosted deploy. On a packet whose `deploy.target` is not `local-serve` it takes an https `--base-url` naming the campaign's route root (plain http only on a loopback host), requests every built page there, and requires each to answer HTTP 200. It then requests every script and stylesheet the built pages load from that origin and compares each one's sha256 with the file in the built output, following redirects only within that origin and reading no more bytes than the built file holds; a missing, larger or different asset refuses the record, naming the URL and the built file. A `record build` of other output between the probe and the write refuses the record, on a `local-serve` packet too. A response with no readable body refuses as a transport failure, not as a byte mismatch. HTML is not compared, because hosts inject toolbars and other markup. A passing probe writes what the loopback path writes: `deploy.preview_url` on the packet, and `stages.deploy` completed with the URL in `outputs`, one evidence line per page and per matched asset, and the build fingerprint it probed, so a later `record build` of other output makes deploy owed again. `local-serve` packets keep the loopback-only rules. The hosted all-template preview no longer skips the deploy stage: `next` hands off `record deploy` for the recorded preview URL and then QA, and the hosted deploy prompt names `record deploy` in place of hand-editing `stages.deploy` (campaigns-os#657).
20
+
21
+ ## [1.58.0+agent.7] - 2026-10-09
22
+
23
+ ### Changed
24
+
25
+ The vendored starter catalog is re-synced to campaign-cart-starter-templates `d5ff501`, which carries the multi-step checkout contract from campaign-cart-starter-templates#223 and the Campaign Cart SDK 0.4.42 pin. Builds made with 1.58.0's `next_step` frontmatter now take templates whose step forms and select links read `next_step` and whose `next-success-url` is the Checkout's post-payment destination. Every certified family records 0.4.42 verification evidence (`sdk-0.4.42-2026-10-09`), and the eight agent CampaignSpec fixtures pin `sdk_version` 0.4.42. The certified-family fixtures are regenerated at the pin, and the payment-chrome asset pin moves to `d5ff501`; the five asset hashes are unchanged.
26
+
27
+ The SDK attribute index moves to v0.4.42 with the same recipe; it returns the same 149 names as at v0.4.41. 0.4.42 joins the cart-placeholder verified pins: the ten renderer and enhancer files the placeholder lists come from have the same git blob ids at v0.4.38 and v0.4.42. The checkout field-name mapping is unchanged at v0.4.42. The SDK support policy names 0.4.42 as `latest_known_release`; `minimum_supported` and `preferred_minimum` are unchanged.
28
+
29
+ ## [1.58.0+agent.6] - 2026-10-09
30
+
31
+ ### Fixed
32
+
33
+ On a select page whose bundle slots each need a variant (size, colour) before Next, `qa run --test-order` now chooses one per slot before clicking the checkout control. It does this after revealing the second step and after any bundle card click, one field at a time, over the visible, enabled slot variant selects (`[data-next-variant-selectors] select` or `.next-slot-variant-field select`, counted visible when the field is). When a field shows a dropdown UI (`os-dropdown` with a `.os-card__variant-dropdown-toggle` and `os-dropdown-item` rows, as the olympus-mv-two-step starter renders), the runner clicks the toggle and a row like a shopper, even when the SDK has already filled the select, because a page may count a field as chosen only when a row was clicked. It clicks the row matching the select's current value when that row is in stock, so the cart stays what the SDK chose, and otherwise the first in-stock row. After the toggle click it finds the field again by slot identity and the row again by its value, and waits up to 2 seconds for that row to show. A field with no dropdown UI gets its first in-stock option only when its select is empty, with `input` and `change` dispatched. Each choice is recorded on the `entered_via_landing` evidence as `variant_selections: [{ bundle_id, slot, variant_code, value, via }]`, with `via` set to `dropdown` or `select`, so the ordered variants can be checked against the cart lines. A field with no in-stock row or option, or a row that does not show or cannot be clicked, fails the path before Next with the new code `cart_entry_variant_unfilled`, naming the slot, instead of `cart_entry_no_navigation` after the navigation timeout. It classifies as `not_created` like the other cart-entry codes. A page whose filled selects have no dropdown UI is left untouched and `variant_selections` is omitted. Before this, every path on a page that blocks Next until a variant is picked failed with `cart_entry_no_navigation` (campaigns-os#667).
34
+ ## [1.58.0+agent.4] - 2026-10-09
35
+
36
+ ### Fixed
37
+
38
+ A QA run that re-ran failed order paths no longer reads as more paths than it planned. Each `test_orders[]` entry in the verdict now carries `attempt` (1 for the first try, 2 for the re-run) beside the `plan_id` of the planned path it belongs to, and `qa run` prints a summary line that keeps paths, attempts and created orders apart, such as `Test orders: 5 paths, 10 attempts, 0 orders created`. Before this, a run capped at five paths where every path was re-run once showed ten entries and nothing to say which were retries. The Assembly Report's `stages.qa.purchase_proof` gains `test_order_attempts`, and `order_paths_executed` now counts planned paths rather than attempts. Verdicts written before `attempt` existed count each entry as a path, as they did before. The array keeps one entry per attempt and no existing field changed shape.
39
+
40
+ ## [1.58.0+agent.3] - 2026-10-09
41
+
42
+ ### Fixed
43
+
44
+ When the built output no longer matches the recorded build fingerprint, doctor's `built_output.fingerprint_stale` and the `polish capture` refusal now name the paths that are extra, missing, or changed, at most 20 per list followed by `(+N more)`. Before, both reported only the recorded and current hashes, so finding the files behind a mismatch took a manual diff of `_site/`. `record build` keeps the path and SHA-256 list behind the fingerprint on `stages.assembly.build_manifest` to make the comparison possible; the field is additive and the fingerprint value is unchanged. A file or folder named like a macOS or iCloud sync conflict copy (`<name> 2.<ext>` beside `<name>.<ext>`) is marked as one, with a hint to remove the copies and rebuild. A build recorded before this change carries no manifest: the refusal is unchanged and says to run `record build` again to record one (campaigns-os#659).
45
+
46
+ ## [1.58.0+agent.2] - 2026-10-09
47
+
48
+ ### Fixed
49
+
50
+ - Doctor, and so `start` and `build`, warns `template_contract.select_step_missing` before build when the selected template family puts package selection on its own `select` page and its checkout carries no selector, but no enabled `select` page in the Map leads to a Checkout. The certified family affected today is `olympus-mv-two-step`. Without the select page, QA resolves the landing as the entry page, finds no add-to-cart control, and every order path fails after build, polish and deploy. The warning names the Checkout and funnel and the fix: add a Select page between the entry page and checkout in Map Builder. It stays silent when a `select` page routes to that Checkout, when the family's checkout carries its own selector, and for a Checkout pinned to another family through `sdk_hints.template_family`. The family's shape is read from the commerce surface catalog's `selectStep` and `mainSelector` surfaces. The warning does not block intake (campaigns-os#658).
51
+ - `copy.hardcoded_phone` no longer reads digit runs inside HTML comments, `<style>` blocks or CSS comments as phone numbers, such as a design-export frame name like `Group 1000003339`. Visible phone numbers still warn, at their original line numbers (campaigns-os#660).
52
+
53
+ ## [1.58.0] - 2026-10-09
54
+
55
+ ### Changed
56
+
57
+ Page Kit frontmatter for multi-step checkout paths follows the starter-template contract adopted in NextCommerceCo/campaign-cart-starter-templates#223. On a `select` or `checkout_step` page on a path to a Checkout, `next_url` is now the Checkout's post-payment destination, the meaning `next_url` has on every checkout page; the layouts render it as `next-success-url`, which the SDK reads for express orders. The page's forward link moves to the new `next_step` key, which the templates render as the step form's `data-next-checkout-step` and the select page's checkout link. `step_number` is unchanged. `success_url` is no longer emitted as a frontmatter key; it stays a CampaignSpec field on `checkout` pages, and the Build Context and Build Packet schemas still accept it so packets written by 1.56.0 and 1.57.0 validate. A landing page that feeds a Checkout keeps `next_url` as its call-to-action target, because every certified family's landing call-to-action reads it; it no longer carries `success_url`, and the build prompt takes an express landing page's `next-success-url` from the Checkout's own `next_url`. The build prompt and the `next-campaigns-build` skill describe the new keys. Bundled skills advance to `1.58.0+skills.1` (campaigns-os#641).
58
+
59
+ ## [1.57.0+agent.1] - 2026-10-08
60
+
61
+ ### Fixed
62
+
63
+ Reading a 4.2 or 4.3 spec no longer turns a `checkout → checkout` chain linked through `success_url` into Checkout Steps with no way forward. A page retyped `checkout_step` keeps the link it followed as a checkout: the resolved forward target is written to `next_page` and `success_url` is dropped, because routing ignores `success_url` on a step. Before this, the upgraded spec failed `CheckoutStepReachesCheckout` and warned `RouteFieldIgnoredForPageType` on every step. A `next_page` the checkout never took (shadowed by `success_url`) is replaced by the followed link. Chains already linked through `next_page`, and 5.x specs, read as before.
64
+
65
+ ## [1.57.0] - 2026-10-08
66
+
67
+ ### Changed
68
+
69
+ - Publishes the untagged `1.56.0` changes: CampaignSpec 5.0, checkout steps, and the updated build and QA contracts.
70
+ - Publishes `1.56.0+agent.1`: `--version` and `-v`, and the Git ignore entry for runtime progress records.
71
+ - Publishes `1.56.0+agent.2`: hosted all-template previews carry missing Polish and page-load evidence forward as warnings.
72
+ - Publishes `1.56.0+agent.3`: doctor and QA find an entry page at Page Kit's emitted route when the declared root is absent.
73
+ - Publishes `1.56.0+agent.5`: doctor warns when a variant and quantity checkout does not fit the selected template family.
74
+ - Publishes `1.56.0+agent.6`: committed handoff artifacts retain repository-relative paths.
75
+
76
+ ## [1.56.0+agent.6] - 2026-10-08
77
+
78
+ ### Fixed
79
+
80
+ Committed Build Packet and `.campaign-runtime/` handoff artifacts now retain filesystem paths in relative form after `doctor --write`, `next`, and stage updates. Packet input fields resolve from the packet file's directory, output paths from the target repository, source page paths from the source root, package references from their context or report file, and normalized brief input paths from the brief file. Other committed sidecar paths and stored commands resolve from the target repository root. Outside inputs use ordinary relative paths from those bases, including source manifest paths in decision evidence. Machine-local ignored run state keeps its existing path behavior. `doctor --strip-paths` remains available for older packets. QA verdict discovery resolves recorded outputs from the target root.
81
+
82
+ ## [1.56.0+agent.5] - 2026-10-08
83
+
84
+ ### Fixed
85
+
86
+ Doctor warns once per checkout path when selectable packages form a variant and quantity matrix but the selected certified template family declares one main package and no configurable variant-slot checkout surface. The matrix requires at least two variants, with at least one offered at multiple quantities. Variant identity comes from attributes shared by every selectable row carrying a quantity, or from variant names shared by those rows; rows with missing or blank quantities are ignored when detecting the matrix. SKUs, order bumps, upsells, pack-size rows, and quantity tiers of one variant do not trigger the warning. Paths follow CampaignSpec route targets, including `.html` and `page_url` routes. If any checkout path has a `select` or `checkout_step` page, every warning ranks multi-step families first. Each warning names the matching certified variant-slot families in ranked order, preferring the selected family's base. It says the family is chosen at intake with `--template-family`; after stage evidence is recorded, it explains that changing the family requires destructive intake with `--force`. The warning does not block intake or change the selected family.
87
+
88
+ ## [1.56.0+agent.3] - 2026-10-08
89
+
90
+ ### Fixed
91
+
92
+ Doctor recognizes an entry page declared at the campaign root when Page Kit emitted its recorded page file at a different route and the root output is absent. Root declarations include `page_url` empty, `/`, `/<slug>/`, and `<slug>`. A template-stock entry counts as built instead of "not built yet"; one warning names the unserved root, the emitted route, and the concrete redirect or `permalink: /<slug>/` repair. The warning uses the public route (`/` or `/<slug>/`), while built-page and previewable routes retain their relative Page Kit format. `qa resolve` prints the locally emitted entry URL under its existing slug-prefixed `base_url`; `base_url` and non-entry page URLs keep their previous resolution, including a `--base-url` subpath. An explicit non-root permalink on the entry source wins over a root output or stale file-route output for QA, and doctor warns if the root output is absent. When files named for both the page ID and page type exist, doctor and QA prefer the page ID file's permalink or built file route, then the page type file's. Without a permalink, a present root output keeps the entry at `base_url` and suppresses the warning. Neither doctor nor QA substitutes an unrelated built page.
93
+
94
+ ## [1.56.0+agent.2] - 2026-10-08
95
+
96
+ ### Changed
97
+
98
+ When every mapped page is template stock, a non-loopback hosted preview at `deploy.preview_url` carries missing Polish and hidden eager-media page-load evidence forward as warnings if its origin or campaign route path differs from `deploy.production_url` after QA composes the campaign slug onto each URL; a query or fragment alone does not qualify. Doctor and `next` move past Polish, and QA records warning rows with a verdict no better than `ready_with_exceptions`. QA against the production campaign page and hosted campaigns with a capturable design route retain strict evidence gates. If the preview URL is missing or resolves to the production campaign page, `next` names `qa policy set --packet <packet> --preview-url <url>` even when a production URL is set. Once recorded, `next` names browser and typed-card QA with `qa run --packet <packet> --base-url <preview-url> --browser --test-order common`. The hosted preview satisfies the deploy handoff without a deploy record; after a current-build QA verdict, `next` reaches closeout. `record deploy` remains local-only. The Polish prompt and actions do not suggest a capture when no design route exists. Bundled skills advance to `1.56.0+skills.2`.
99
+
100
+ ## [1.56.0+agent.1] - 2026-10-08
101
+
102
+ ### Fixed
103
+
104
+ - `campaigns-os --version` and `campaigns-os -v` print only the installed package version, with no campaign runtime writes (campaigns-os#644).
105
+ - Campaign runtime progress snapshots and their remit records are ignored by Git. A target with an older managed `.gitignore` block gains `.campaign-runtime/progress/` on its next `start`, `prepare-build`, `install-agent-context` or `run start`. A rule does not untrack files already committed; remove those with `git rm -r --cached .campaign-runtime/progress/` (campaigns-os#645).
106
+
107
+ ## [1.56.0] - 2026-10-08
108
+
109
+ ### Added
110
+
111
+ CampaignSpec 5.0 (`schemas/campaign-spec.v5.schema.json`, also a package export) makes `checkout` mean the page that takes payment and adds the `checkout_step` page type: a page with a checkout form that collects details and moves on through `next_page` without placing an order. `select` stays the bundle picker before checkout. All three render as SDK page type `checkout`. A 4.2 or 4.3 spec, including a Map saved before this release, still validates against the v4 schema and is upgraded when read: each `checkout` page whose resolved forward target is another `checkout` becomes `checkout_step`. The input is never rewritten, so spec hashes still cover the bytes as written. A campaign may hold several Checkouts, one per split-test path (campaigns-os#641).
112
+
113
+ Four new spec rules run in doctor through `spec.validation`. Each has a fixture under `campaign-spec/fixtures/`, and none of them reads page order or page names:
114
+
115
+ - `CheckoutForwardTarget` (error): a Checkout's resolved forward target is not an upsell, downsell or thank-you page.
116
+ - `PrePaymentForwardTarget` (error): a `select` or `checkout_step` page forwards straight to an upsell, downsell or thank-you page.
117
+ - `OneCheckoutPerPath` (error): a path from an entry page reaches an upsell or downsell without passing exactly one Checkout. In partial build scope, the case with no Checkout is a warning.
118
+ - `CheckoutStepReachesCheckout` (error): a `checkout_step` page is not on a path to a Checkout.
119
+
120
+ `RouteFieldIgnoredForPageType` now also warns about a `success_url` or `on_accept` on a `checkout_step`.
121
+
122
+ ### Changed
123
+
124
+ - Build: `prepare-build` projects `checkout_step` pages to Page Kit `page_type: checkout`, with the default route `checkout-step/`. Page Kit frontmatter in the Build Packet and Build Context gains two keys. `success_url` goes on every page that feeds a Checkout and holds that Checkout's post-payment destination, for the `next-success-url` meta tag the SDK reads for express orders. `step_number` goes on each `checkout_step` and is its position on its path, for the step form's `data-next-step-number`. The build prompt and the `next-campaigns-build` skill now describe the select-page handoff the templates use: the swap-mode bundle selector writes the cart, the SDK persists it, and a checkout button or link moves on. `forcePackageId` is only for a page with no selector. Step forms carry `data-next-checkout-step` and `data-next-step-number`.
125
+ - QA: test orders drive the Checkout on the tested path instead of the first page typed `checkout`. On a multi-step path the runner enters the cart on the select page or the first step. It submits each `checkout_step` form and checks the page lands on the step's declared next page, recording one `checkout_step_submitted` ladder rung per step. The order is submitted only on the Checkout. On a page with bundle cards, the cards plus a checkout button now count as a cart-entry control (`control_kind: checkout_button`), and `--select-package` clicks the matching `data-next-bundle-card`. `tiers` plans read tiers and bumps from every page of each Checkout's path. The legacy API test order takes its success URL from the Checkout. The theme gate covers `select` and `checkout_step` pages.
126
+ - Doctor and the Build Brief treat exit intent, promo-code input and order bumps on `select` and `checkout_step` pages like those on a Checkout. The payment-method residue scan also reads built `select` and `checkout_step` pages, where express buttons render. The commercial journey summary prices the cart on the checkout-flow page that carries it.
127
+ - The runtime recipe enumerates the five new `campaign-spec` sources (revision `1.0.3`).
128
+
5
129
  ## [1.55.0] - 2026-10-07
6
130
 
7
131
  ### Changed
package/CONTEXT.md CHANGED
@@ -37,6 +37,17 @@ applied brand tokens, and pricing-surface modes. QA reads it to fail "still
37
37
  visually the starter template" deterministically.
38
38
  _Avoid_: style guide, design tokens doc, theme report
39
39
 
40
+ **Checkout (payment page)**:
41
+ The page on a checkout path that takes payment: CampaignSpec `type: "checkout"`
42
+ (campaigns-os#641). It forwards to an upsell, downsell or thank-you page, and
43
+ every path from an entry page to an upsell passes exactly one. Pages ahead of
44
+ it render the same SDK checkout surface without placing an order: a `select`
45
+ page (the bundle picker) and `checkout_step` pages (a split checkout's
46
+ information and shipping forms). A campaign may hold several Checkouts, one
47
+ per split-test path; every consumer asks for "the Checkout on this path"
48
+ (`campaign-spec/checkout-flow.ts`), never for the first page typed checkout.
49
+ _Avoid_: first checkout, checkout page (for a step), final step
50
+
40
51
  **Template Reference**:
41
52
  The canonical baseline for a template family: its safe runtime structure,
42
53
  expected screenshots or render references, default assets, starter residue
@@ -16,7 +16,9 @@ rules are pure TypeScript over a normalized spec with no heavy dependencies.
16
16
  campaign-spec/
17
17
  index.ts # public interface
18
18
  types.ts # CampaignSpec, Rule, Violation, Tag, Severity
19
- normalize.ts # v4.3 authoring → canonical v4.2 funnels[] shape
19
+ normalize.ts # authoring → canonical funnels[] shape; upgrades v4.2/v4.3 to v5
20
+ checkout-flow.ts # which page takes payment; v4 → v5 upgrade; path helpers
21
+ routing.ts # a page's outgoing edges
20
22
  rules/
21
23
  index.ts # preset RuleSet constants
22
24
  cycle-detection.ts # one file per rule
@@ -131,6 +133,18 @@ IIFE) and expose the public interface on `window` to run export-time validation
131
133
  client-side. The rules have no Node-only or live-data dependencies, so the same
132
134
  registry that backs the CLI doctor runs unchanged in the browser.
133
135
 
136
+ ## CampaignSpec 5.0: the page that takes payment
137
+
138
+ `checkout` is the page that takes payment; `checkout_step` is a checkout form
139
+ that collects details and moves on through `next_page`; `select` is the bundle
140
+ picker before them. `normalize()` upgrades a 4.2/4.3 spec to that vocabulary
141
+ on read (each `checkout` whose resolved forward target is another `checkout`
142
+ becomes `checkout_step`) and never mutates its input. Four rules hold the
143
+ shape — `CheckoutForwardTarget`, `PrePaymentForwardTarget`,
144
+ `CheckoutStepReachesCheckout` and `OneCheckoutPerPath` — and none of them
145
+ reads page order or page names. `paymentPageFrom`, `checkoutPathFrom` and
146
+ `paymentPagesForFunnel` answer "which Checkout is on this path".
147
+
134
148
  ## See also
135
149
 
136
150
  - `../CONTEXT.md` — domain vocabulary.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * checkout-flow — which page takes payment, and the pages that lead to it.
3
+ *
4
+ * From CampaignSpec v5 a page's `type` says which page takes payment
5
+ * (campaigns-os#641). Three types carry the shopper into a payment:
6
+ *
7
+ * | Type | Checkout form | Places order | Forward field |
8
+ * |-----------------|---------------|--------------|---------------|
9
+ * | `select` | no | no | `next_page` |
10
+ * | `checkout_step` | yes | no | `next_page` |
11
+ * | `checkout` | yes | yes | `success_url` |
12
+ *
13
+ * All three render as SDK page type `checkout`. A campaign may hold several
14
+ * Checkouts (one per split-test path); each path reaches exactly one.
15
+ *
16
+ * Every consumer that asks "which page on this path takes payment" asks here,
17
+ * never "the first page typed checkout". The one place the payment page is
18
+ * worked out from links rather than read from `type` is `upgradeCampaignSpec`,
19
+ * which retypes a v4 spec on read.
20
+ */
21
+ import type { Funnel, Page } from './types.ts';
22
+ /** The CampaignSpec lineage this module writes when it upgrades a v4 spec. */
23
+ export declare const CURRENT_SCHEMA_VERSION: "5.0";
24
+ /** The page that takes payment. */
25
+ export declare const PAYMENT_PAGE_TYPE: "checkout";
26
+ /** A checkout form that collects details and moves on without placing an order. */
27
+ export declare const CHECKOUT_STEP_PAGE_TYPE: "checkout_step";
28
+ /** Pages that lead into a Checkout without taking payment themselves. */
29
+ export declare const PRE_PAYMENT_PAGE_TYPES: readonly ["select", "checkout_step"];
30
+ /**
31
+ * Every page type the SDK renders as `checkout`: the pre-payment pages and the
32
+ * Checkout itself. Payment-page features (exit intent, promo code input,
33
+ * checkout theme gates) apply to all of them.
34
+ */
35
+ export declare const CHECKOUT_FLOW_PAGE_TYPES: readonly ["select", "checkout_step", "checkout"];
36
+ /** Where a Checkout may send the shopper once payment succeeds. */
37
+ export declare const POST_PAYMENT_PAGE_TYPES: readonly ["upsell", "downsell", "thankyou"];
38
+ /** Post-purchase offer pages: reaching one means an order was already placed. */
39
+ export declare const POST_PURCHASE_OFFER_PAGE_TYPES: readonly ["upsell", "downsell"];
40
+ export declare function isPaymentPage(page: Page | null | undefined): boolean;
41
+ export declare function isCheckoutStepPage(page: Page | null | undefined): boolean;
42
+ export declare function isPrePaymentPage(page: Page | null | undefined): boolean;
43
+ export declare function isCheckoutFlowPage(page: Page | null | undefined): boolean;
44
+ /**
45
+ * The page a route target names within `pages`, or null when it names none
46
+ * (an absolute URL, a fragment, a route outside this funnel, or a typo that
47
+ * RouteTargetResolves reports). Page IDs win over `page_url` matches.
48
+ */
49
+ export declare function resolveRouteTargetPage(pages: readonly Page[], target: string | null | undefined): Page | null;
50
+ /** The page this page's forward link resolves to, within `pages`. */
51
+ export declare function forwardTargetPage(pages: readonly Page[], page: Page | null | undefined): Page | null;
52
+ /**
53
+ * The pages from `start` forward to the Checkout that takes payment, both
54
+ * included, following each page's forward link. Null when the walk leaves the
55
+ * funnel, loops, ends, or meets a post-payment page before any Checkout.
56
+ *
57
+ * `start` may itself be the Checkout (the path is then that page alone).
58
+ */
59
+ export declare function checkoutPathFrom(pages: readonly Page[], start: Page | null | undefined): Page[] | null;
60
+ /** The Checkout reached by walking forward from `start`, or null. */
61
+ export declare function paymentPageFrom(pages: readonly Page[], start: Page | null | undefined): Page | null;
62
+ /**
63
+ * The funnel's entry pages: those flagged `is_entry`, or when none is flagged,
64
+ * those no other page routes to apart from orphaned upsell, downsell and
65
+ * thank-you pages (falling back to the lowest `order` when that leaves none). Order and names are never consulted beyond that
66
+ * last-resort tie-break.
67
+ */
68
+ export declare function entryPages(funnel: Funnel | null | undefined): Page[];
69
+ /**
70
+ * The Checkout on the path through `page`'s funnel that starts at an entry
71
+ * page, for callers that hold a funnel rather than a starting page. When the
72
+ * funnel has several entry paths with different Checkouts, returns the first
73
+ * entry's; use `paymentPagesForFunnel` to see all of them.
74
+ */
75
+ export declare function paymentPageForFunnel(funnel: Funnel | null | undefined): Page | null;
76
+ /** Every distinct Checkout reached from the funnel's entry pages, in entry order. */
77
+ export declare function paymentPagesForFunnel(funnel: Funnel | null | undefined): Page[];
78
+ /**
79
+ * Upgrade a CampaignSpec to the v5 page vocabulary. Returns a new object; the
80
+ * input is never mutated, so callers that hash or persist the raw bytes keep
81
+ * the bytes they read.
82
+ *
83
+ * v5 specs pass through unchanged. For any other lineage, each `checkout` page
84
+ * whose resolved forward target is also a `checkout` is retyped
85
+ * `checkout_step` — the v4 `checkout → checkout` chain was the only way to say
86
+ * "this form moves on without taking payment". A supported v4 `schema_version`
87
+ * becomes `5.0`; a missing or unsupported one is left for SchemaVersion to
88
+ * report.
89
+ *
90
+ * A retyped page keeps the link it followed as a checkout. A `checkout_step`
91
+ * moves on through `next_page` only (routing.ts ignores `success_url` on it),
92
+ * so a v4 chain linked through `success_url` would otherwise upgrade into a
93
+ * step with no way forward and fail CheckoutStepReachesCheckout. The resolved
94
+ * forward target is written to `next_page` and `success_url` is dropped; a
95
+ * `next_page` the checkout shadowed (never taken) is replaced by it.
96
+ */
97
+ export declare function upgradeCampaignSpec<T>(input: T): T;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * checkout-flow — which page takes payment, and the pages that lead to it.
3
+ *
4
+ * From CampaignSpec v5 a page's `type` says which page takes payment
5
+ * (campaigns-os#641). Three types carry the shopper into a payment:
6
+ *
7
+ * | Type | Checkout form | Places order | Forward field |
8
+ * |-----------------|---------------|--------------|---------------|
9
+ * | `select` | no | no | `next_page` |
10
+ * | `checkout_step` | yes | no | `next_page` |
11
+ * | `checkout` | yes | yes | `success_url` |
12
+ *
13
+ * All three render as SDK page type `checkout`. A campaign may hold several
14
+ * Checkouts (one per split-test path); each path reaches exactly one.
15
+ *
16
+ * Every consumer that asks "which page on this path takes payment" asks here,
17
+ * never "the first page typed checkout". The one place the payment page is
18
+ * worked out from links rather than read from `type` is `upgradeCampaignSpec`,
19
+ * which retypes a v4 spec on read.
20
+ */
21
+ import { forwardRouteTarget, outgoingEdgeIds } from "./routing.js";
22
+ /** The CampaignSpec lineage this module writes when it upgrades a v4 spec. */
23
+ export const CURRENT_SCHEMA_VERSION = '5.0';
24
+ /** The page that takes payment. */
25
+ export const PAYMENT_PAGE_TYPE = 'checkout';
26
+ /** A checkout form that collects details and moves on without placing an order. */
27
+ export const CHECKOUT_STEP_PAGE_TYPE = 'checkout_step';
28
+ /** Pages that lead into a Checkout without taking payment themselves. */
29
+ export const PRE_PAYMENT_PAGE_TYPES = Object.freeze(['select', 'checkout_step']);
30
+ /**
31
+ * Every page type the SDK renders as `checkout`: the pre-payment pages and the
32
+ * Checkout itself. Payment-page features (exit intent, promo code input,
33
+ * checkout theme gates) apply to all of them.
34
+ */
35
+ export const CHECKOUT_FLOW_PAGE_TYPES = Object.freeze(['select', 'checkout_step', 'checkout']);
36
+ /** Where a Checkout may send the shopper once payment succeeds. */
37
+ export const POST_PAYMENT_PAGE_TYPES = Object.freeze(['upsell', 'downsell', 'thankyou']);
38
+ /** Post-purchase offer pages: reaching one means an order was already placed. */
39
+ export const POST_PURCHASE_OFFER_PAGE_TYPES = Object.freeze(['upsell', 'downsell']);
40
+ function typeOf(page) {
41
+ const type = page?.type;
42
+ return typeof type === 'string' ? type.trim().toLowerCase() : '';
43
+ }
44
+ export function isPaymentPage(page) {
45
+ return typeOf(page) === PAYMENT_PAGE_TYPE;
46
+ }
47
+ export function isCheckoutStepPage(page) {
48
+ return typeOf(page) === CHECKOUT_STEP_PAGE_TYPE;
49
+ }
50
+ export function isPrePaymentPage(page) {
51
+ return PRE_PAYMENT_PAGE_TYPES.includes(typeOf(page));
52
+ }
53
+ export function isCheckoutFlowPage(page) {
54
+ return CHECKOUT_FLOW_PAGE_TYPES.includes(typeOf(page));
55
+ }
56
+ /**
57
+ * Strip surrounding slashes and a `.html` suffix so a route target, a page ID
58
+ * and a `page_url` compare on equal terms. The corpus writes targets in all
59
+ * three dialects (`shipping`, `shipping.html`, `/shipping/`). Case is kept, as
60
+ * in RouteTargetResolves: intake's lookup is case-sensitive.
61
+ */
62
+ function routeish(value) {
63
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
64
+ return trimmed.toLowerCase().endsWith('.html') ? trimmed.slice(0, -'.html'.length) : trimmed;
65
+ }
66
+ /**
67
+ * The page a route target names within `pages`, or null when it names none
68
+ * (an absolute URL, a fragment, a route outside this funnel, or a typo that
69
+ * RouteTargetResolves reports). Page IDs win over `page_url` matches.
70
+ */
71
+ export function resolveRouteTargetPage(pages, target) {
72
+ if (typeof target !== 'string' || !target.trim())
73
+ return null;
74
+ const raw = target.trim();
75
+ if (raw.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(raw))
76
+ return null;
77
+ const key = routeish(raw);
78
+ if (!key)
79
+ return null;
80
+ for (const page of pages) {
81
+ if (page?.id === raw || (typeof page?.id === 'string' && routeish(page.id) === key))
82
+ return page;
83
+ }
84
+ for (const page of pages) {
85
+ const route = page?.page_url;
86
+ if (typeof route === 'string' && route.trim() && routeish(route) === key)
87
+ return page;
88
+ }
89
+ return null;
90
+ }
91
+ /** The page this page's forward link resolves to, within `pages`. */
92
+ export function forwardTargetPage(pages, page) {
93
+ return resolveRouteTargetPage(pages, forwardRouteTarget(page));
94
+ }
95
+ function enabledPages(funnel) {
96
+ return (funnel?.pages ?? []).filter((page) => page && page.enabled !== false);
97
+ }
98
+ /**
99
+ * The pages from `start` forward to the Checkout that takes payment, both
100
+ * included, following each page's forward link. Null when the walk leaves the
101
+ * funnel, loops, ends, or meets a post-payment page before any Checkout.
102
+ *
103
+ * `start` may itself be the Checkout (the path is then that page alone).
104
+ */
105
+ export function checkoutPathFrom(pages, start) {
106
+ const path = [];
107
+ const seen = new Set();
108
+ let page = start;
109
+ while (page && !seen.has(page)) {
110
+ seen.add(page);
111
+ path.push(page);
112
+ if (isPaymentPage(page))
113
+ return path;
114
+ if (POST_PAYMENT_PAGE_TYPES.includes(typeOf(page)))
115
+ return null;
116
+ page = forwardTargetPage(pages, page);
117
+ }
118
+ return null;
119
+ }
120
+ /** The Checkout reached by walking forward from `start`, or null. */
121
+ export function paymentPageFrom(pages, start) {
122
+ const path = checkoutPathFrom(pages, start);
123
+ return path ? path[path.length - 1] : null;
124
+ }
125
+ /**
126
+ * The funnel's entry pages: those flagged `is_entry`, or when none is flagged,
127
+ * those no other page routes to apart from orphaned upsell, downsell and
128
+ * thank-you pages (falling back to the lowest `order` when that leaves none). Order and names are never consulted beyond that
129
+ * last-resort tie-break.
130
+ */
131
+ export function entryPages(funnel) {
132
+ const pages = enabledPages(funnel);
133
+ const flagged = pages.filter((page) => page.is_entry === true);
134
+ if (flagged.length)
135
+ return flagged;
136
+ const targeted = new Set();
137
+ for (const page of pages) {
138
+ for (const target of outgoingEdgeIds(page)) {
139
+ const hit = resolveRouteTargetPage(pages, target);
140
+ if (hit && hit !== page)
141
+ targeted.add(hit);
142
+ }
143
+ }
144
+ // A post-payment page nothing routes to is an orphan, not where a shopper
145
+ // starts: no unflagged journey begins on an upsell or a receipt.
146
+ const roots = pages.filter((page) => !targeted.has(page) && !POST_PAYMENT_PAGE_TYPES.includes(typeOf(page)));
147
+ if (roots.length)
148
+ return roots;
149
+ const first = [...pages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[0];
150
+ return first ? [first] : [];
151
+ }
152
+ /**
153
+ * The Checkout on the path through `page`'s funnel that starts at an entry
154
+ * page, for callers that hold a funnel rather than a starting page. When the
155
+ * funnel has several entry paths with different Checkouts, returns the first
156
+ * entry's; use `paymentPagesForFunnel` to see all of them.
157
+ */
158
+ export function paymentPageForFunnel(funnel) {
159
+ return paymentPagesForFunnel(funnel)[0] ?? null;
160
+ }
161
+ /** Every distinct Checkout reached from the funnel's entry pages, in entry order. */
162
+ export function paymentPagesForFunnel(funnel) {
163
+ const pages = enabledPages(funnel);
164
+ const out = [];
165
+ for (const entry of entryPages(funnel)) {
166
+ const checkout = paymentPageFrom(pages, entry);
167
+ if (checkout && !out.includes(checkout))
168
+ out.push(checkout);
169
+ }
170
+ // A funnel whose entry is a landing page that links into checkout through a
171
+ // CTA (no forward field) still has a Checkout; fall back to every Checkout
172
+ // the funnel declares, in declaration order (funnel.pages), so a lookup never
173
+ // reports "none" for a real one. No entry path reaches these, so there is no
174
+ // path order to follow; QA's topology walker falls back the same way.
175
+ if (!out.length)
176
+ for (const page of pages)
177
+ if (isPaymentPage(page))
178
+ out.push(page);
179
+ return out;
180
+ }
181
+ /**
182
+ * Upgrade a CampaignSpec to the v5 page vocabulary. Returns a new object; the
183
+ * input is never mutated, so callers that hash or persist the raw bytes keep
184
+ * the bytes they read.
185
+ *
186
+ * v5 specs pass through unchanged. For any other lineage, each `checkout` page
187
+ * whose resolved forward target is also a `checkout` is retyped
188
+ * `checkout_step` — the v4 `checkout → checkout` chain was the only way to say
189
+ * "this form moves on without taking payment". A supported v4 `schema_version`
190
+ * becomes `5.0`; a missing or unsupported one is left for SchemaVersion to
191
+ * report.
192
+ *
193
+ * A retyped page keeps the link it followed as a checkout. A `checkout_step`
194
+ * moves on through `next_page` only (routing.ts ignores `success_url` on it),
195
+ * so a v4 chain linked through `success_url` would otherwise upgrade into a
196
+ * step with no way forward and fail CheckoutStepReachesCheckout. The resolved
197
+ * forward target is written to `next_page` and `success_url` is dropped; a
198
+ * `next_page` the checkout shadowed (never taken) is replaced by it.
199
+ */
200
+ export function upgradeCampaignSpec(input) {
201
+ if (input == null || typeof input !== 'object' || Array.isArray(input))
202
+ return input;
203
+ const spec = input;
204
+ const version = typeof spec.schema_version === 'string' ? spec.schema_version.trim() : '';
205
+ if (version.startsWith('5.') || version === '5')
206
+ return input;
207
+ if (!Array.isArray(spec.funnels))
208
+ return input;
209
+ let changed = false;
210
+ const funnels = spec.funnels.map((funnel) => {
211
+ if (!funnel || !Array.isArray(funnel.pages))
212
+ return funnel;
213
+ const pages = funnel.pages;
214
+ const retype = new Set();
215
+ for (const page of pages) {
216
+ if (!isPaymentPage(page))
217
+ continue;
218
+ if (isPaymentPage(forwardTargetPage(pages, page)))
219
+ retype.add(page);
220
+ }
221
+ if (!retype.size)
222
+ return funnel;
223
+ changed = true;
224
+ return {
225
+ ...funnel,
226
+ pages: pages.map((page) => {
227
+ if (!retype.has(page))
228
+ return page;
229
+ const step = { ...page, type: CHECKOUT_STEP_PAGE_TYPE };
230
+ const forward = forwardRouteTarget(page);
231
+ if (forward === null)
232
+ return step;
233
+ const { success_url: _ignoredOnStep, ...rest } = step;
234
+ return forward === page.next_page ? rest : { ...rest, next_page: forward };
235
+ }),
236
+ };
237
+ });
238
+ const upgradeVersion = version === '4.2' || version === '4.3';
239
+ if (!changed && !upgradeVersion)
240
+ return input;
241
+ const out = { ...spec, funnels };
242
+ if (upgradeVersion)
243
+ out.schema_version = CURRENT_SCHEMA_VERSION;
244
+ return out;
245
+ }
@@ -15,6 +15,7 @@ export type { CampaignSpec, Rule, RuleSet, Violation, Tag, Severity, Fixture, Pa
15
15
  export { normalize, NormalizeError } from './normalize.ts';
16
16
  export { FORWARD_ROUTE_FIELDS, ACCEPT_ROUTE_FIELD, DECLINE_ROUTE_FIELD, ROUTE_FIELDS, PAYMENT_BEARING_PAGE_TYPES, OFFER_BEARING_PAGE_TYPES, forwardRouteTarget, acceptRouteTarget, declineRouteTarget, hasForwardRoute, applicableForwardFields, inapplicableForwardFields, describeForwardField, outgoingEdgeIds, } from './routing.ts';
17
17
  export type { ForwardFieldApplicability } from './routing.ts';
18
+ export { CURRENT_SCHEMA_VERSION, PAYMENT_PAGE_TYPE, CHECKOUT_STEP_PAGE_TYPE, PRE_PAYMENT_PAGE_TYPES, CHECKOUT_FLOW_PAGE_TYPES, POST_PAYMENT_PAGE_TYPES, POST_PURCHASE_OFFER_PAGE_TYPES, isPaymentPage, isCheckoutStepPage, isPrePaymentPage, isCheckoutFlowPage, resolveRouteTargetPage, forwardTargetPage, checkoutPathFrom, paymentPageFrom, entryPages, paymentPageForFunnel, paymentPagesForFunnel, upgradeCampaignSpec, } from './checkout-flow.ts';
18
19
  export { allRules, fastRules, specOnlyRules } from './rules/index.ts';
19
20
  export { SUPPORTED_SCHEMA_VERSIONS } from './rules/schema-version.ts';
20
21
  export { RELEASED_SDK_VERSION_PATTERN, parseSdkVersion, isReleasedSdkVersion, describeSdkVersionRejection, } from './sdk-version-parse.ts';
@@ -17,10 +17,15 @@ export { normalize, NormalizeError } from "./normalize.js";
17
17
  // page go". Source intake, cycle detection and the QA topology extractor all
18
18
  // consume these rather than keeping their own page-type tables.
19
19
  export { FORWARD_ROUTE_FIELDS, ACCEPT_ROUTE_FIELD, DECLINE_ROUTE_FIELD, ROUTE_FIELDS, PAYMENT_BEARING_PAGE_TYPES, OFFER_BEARING_PAGE_TYPES, forwardRouteTarget, acceptRouteTarget, declineRouteTarget, hasForwardRoute, applicableForwardFields, inapplicableForwardFields, describeForwardField, outgoingEdgeIds, } from "./routing.js";
20
+ // Which page takes payment, and the pages leading to it (campaigns-os#641).
21
+ // Every "the Checkout on this path" lookup asks here; upgradeCampaignSpec is
22
+ // the v4 → v5 read-time upgrade normalize() applies.
23
+ export { CURRENT_SCHEMA_VERSION, PAYMENT_PAGE_TYPE, CHECKOUT_STEP_PAGE_TYPE, PRE_PAYMENT_PAGE_TYPES, CHECKOUT_FLOW_PAGE_TYPES, POST_PAYMENT_PAGE_TYPES, POST_PURCHASE_OFFER_PAGE_TYPES, isPaymentPage, isCheckoutStepPage, isPrePaymentPage, isCheckoutFlowPage, resolveRouteTargetPage, forwardTargetPage, checkoutPathFrom, paymentPageFrom, entryPages, paymentPageForFunnel, paymentPagesForFunnel, upgradeCampaignSpec, } from "./checkout-flow.js";
20
24
  export { allRules, fastRules, specOnlyRules } from "./rules/index.js";
21
25
  // Supported schema_version matrix — single source for the SchemaVersion rule
22
26
  // and any consumer that needs to present or gate on the supported lineages.
23
- // A sync test pins it to the schemas/campaign-spec.v4.schema.json enum.
27
+ // A sync test pins it to the schema_version enums of
28
+ // schemas/campaign-spec.v4.schema.json and schemas/campaign-spec.v5.schema.json.
24
29
  export { SUPPORTED_SCHEMA_VERSIONS } from "./rules/schema-version.js";
25
30
  // Strict released-SDK-version parser — shared with the downstream Page Kit
26
31
  // SDK-version checkpoint (src/page-kit-sdk-version.mjs) so authoring-time
@@ -2,8 +2,9 @@
2
2
  * normalize — the single phase that takes an authoring CampaignSpec and emits
3
3
  * the canonical v4.2 funnels[] shape that rules operate on.
4
4
  *
5
- * Today this is near-empty: v4.3 already uses funnels[]. The phase exists so
6
- * future authoring evolutions have one place to land their migration.
5
+ * v4.2/v4.3 specs are upgraded to the v5 page vocabulary here (a `checkout`
6
+ * that forwards to another `checkout` becomes `checkout_step`), so every rule
7
+ * sees one vocabulary. Future authoring evolutions land here too.
7
8
  *
8
9
  * v4.1 (funnel_pages[]) is intentionally NOT supported — see
9
10
  * ../docs/adr/002-drop-v41-spec-support.md. Inputs without `funnels[]` fail
@@ -2,13 +2,15 @@
2
2
  * normalize — the single phase that takes an authoring CampaignSpec and emits
3
3
  * the canonical v4.2 funnels[] shape that rules operate on.
4
4
  *
5
- * Today this is near-empty: v4.3 already uses funnels[]. The phase exists so
6
- * future authoring evolutions have one place to land their migration.
5
+ * v4.2/v4.3 specs are upgraded to the v5 page vocabulary here (a `checkout`
6
+ * that forwards to another `checkout` becomes `checkout_step`), so every rule
7
+ * sees one vocabulary. Future authoring evolutions land here too.
7
8
  *
8
9
  * v4.1 (funnel_pages[]) is intentionally NOT supported — see
9
10
  * ../docs/adr/002-drop-v41-spec-support.md. Inputs without `funnels[]` fail
10
11
  * the structural assertion below.
11
12
  */
13
+ import { upgradeCampaignSpec } from "./checkout-flow.js";
12
14
  /**
13
15
  * Thrown when input is structurally unrecognizable as a CampaignSpec.
14
16
  * Distinct from rule violations: a violation means "this spec is wrong";
@@ -35,7 +37,7 @@ export function normalize(input) {
35
37
  if (!Array.isArray(obj.funnels)) {
36
38
  throw new NormalizeError('CampaignSpec is missing funnels[]. Expected canonical v4.2+ topology.');
37
39
  }
38
- // Future migrations (v5 → v4, etc.) land here. Today the shape is already
39
- // canonical, so pass through.
40
- return obj;
40
+ // v4 → v5: retype each `checkout` that forwards to another `checkout` as
41
+ // `checkout_step` (campaigns-os#641). Returns a copy; the input is untouched.
42
+ return upgradeCampaignSpec(obj);
41
43
  }
@@ -73,9 +73,12 @@ export declare const DECLINE_ROUTE_FIELD: "on_decline";
73
73
  * is about one field's MEANING: reading a payment-shaped field on a page with
74
74
  * no payment is not respecting the author's intent, it is inventing one.
75
75
  *
76
- * Membership is deliberately narrow. The three-step shop family types its
77
- * `information` / `shipping` / `billing` pages as `checkout`, so they are
78
- * already covered; no certified family carries a second payment-bearing type.
76
+ * Membership is deliberately narrow. From CampaignSpec v5 (campaigns-os#641)
77
+ * `checkout` means exactly the page that takes payment; the earlier steps of a
78
+ * split checkout (the three-step shop family's `information` and `shipping`)
79
+ * are `checkout_step`, which take no payment and advance through `next_page`.
80
+ * A v4 spec that chained `checkout → checkout` is retyped on read
81
+ * (checkout-flow.ts), so the billing page alone stays payment-bearing here.
79
82
  * An `upsell` takes a one-click payment but expresses its post-purchase branch
80
83
  * through `on_accept`, which has its own applicability rule below.
81
84
  * Ratified on campaigns-os#234, 2026-08-25.