@nextcommerce/campaigns-os 1.62.0 → 1.66.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 (74) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/campaign-spec/dist/rules/campaign-currencies.d.ts +1 -0
  3. package/campaign-spec/dist/rules/campaign-currencies.js +1 -1
  4. package/campaign-spec/dist/rules/index.d.ts +1 -0
  5. package/campaign-spec/dist/rules/index.js +3 -0
  6. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  7. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  8. package/campaign-spec/dist/types.d.ts +16 -0
  9. package/compatibility.json +1 -1
  10. package/contracts/effects.v1.json +129 -1
  11. package/contracts/fixtures/runtime-recipe/accept/current.json +1 -0
  12. package/contracts/fixtures/runtime-recipe/accept/minimal.json +1 -0
  13. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  14. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +1 -0
  15. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +1 -0
  16. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +1 -0
  17. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +1 -0
  18. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +1 -0
  19. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +1 -0
  20. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +1 -0
  21. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +1 -0
  22. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +1 -0
  23. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +1 -0
  24. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +1 -0
  25. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +1 -0
  26. package/contracts/release-ledger.json +810 -0
  27. package/contracts/runtime-recipe.campaigns-os-node-v1.json +1 -0
  28. package/contracts/supported-surface.json +6 -6
  29. package/docs/build-packet.md +31 -5
  30. package/docs/campaigns-os-build-flow.md +38 -0
  31. package/docs/campaignspec-authoring-examples.md +31 -0
  32. package/docs/effects.md +8 -0
  33. package/docs/local-setup.md +1 -1
  34. package/docs/orientation-contract-reference.md +1 -1
  35. package/docs/qa-and-test-orders.md +44 -10
  36. package/docs/readback.md +8 -1
  37. package/docs/runtime-readiness.md +4 -3
  38. package/docs/skills-revision.md +10 -10
  39. package/docs/supported-surface.md +1 -1
  40. package/package.json +1 -1
  41. package/schemas/campaign-spec.v4.schema.json +15 -0
  42. package/schemas/campaign-spec.v5.schema.json +15 -0
  43. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  44. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  45. package/skills/campaign-readback-classification/SKILL.md +3 -3
  46. package/skills/campaign-run-evidence/SKILL.md +3 -3
  47. package/skills/contribution-intake/SKILL.md +3 -3
  48. package/skills/next-campaigns-build/SKILL.md +3 -3
  49. package/skills/next-campaigns-os/SKILL.md +3 -3
  50. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  51. package/skills/next-campaigns-polish/SKILL.md +3 -3
  52. package/skills/next-campaigns-qa/SKILL.md +4 -4
  53. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  54. package/skills.json +11 -11
  55. package/src/change-scope.mjs +25 -6
  56. package/src/cli.mjs +120 -7
  57. package/src/commercial-journey.mjs +3 -0
  58. package/src/commercial-parity.mjs +157 -7
  59. package/src/doctor/checks.mjs +28 -3
  60. package/src/doctor/ci.mjs +36 -1
  61. package/src/evidence-debt.mjs +9 -1
  62. package/src/input-currency.mjs +4 -2
  63. package/src/input-refresh.mjs +1 -1
  64. package/src/invocation.mjs +3 -2
  65. package/src/orchestration-stage-contract.mjs +3 -1
  66. package/src/polish-gate.mjs +10 -0
  67. package/src/qa-browser.mjs +46 -0
  68. package/src/qa-commercial-parity.mjs +173 -18
  69. package/src/qa-node.mjs +10 -5
  70. package/src/qa-verdict.mjs +3 -1
  71. package/src/readback.mjs +1 -0
  72. package/src/runtime-state-ignore.mjs +44 -19
  73. package/src/stage-ledger.mjs +35 -9
  74. package/src/stage-record.mjs +93 -14
package/CHANGELOG.md CHANGED
@@ -2,6 +2,77 @@
2
2
 
3
3
  Notable supported-surface changes are recorded here.
4
4
 
5
+ ## [1.66.0] - 2026-11-07
6
+
7
+ Funnels handed off before 1.62.0 can now reach small checks without a test order. To take it, pin `@nextcommerce/campaigns-os@1.66.0`. Nothing changes for a funnel unless a named person runs the new command.
8
+
9
+ ### Added
10
+
11
+ - **`record baseline` (#716).** `campaigns-os record baseline --packet <packet> --accepted-by "<named person>" --reason "<why>"` accepts a handed-off funnel from before markup hashes as the starting point, one time per funnel. It tests nothing and places no orders. After it, a page-only change owes QA on that page alone, and a commerce-affecting change still owes the full Polish and QA. It refuses a funnel whose last QA was blocked, one where no QA ever completed, one whose records have markup hashes, and one where no full QA is owed.
12
+ - **The stage status `baseline_accepted`.** Polish and QA take it from `record baseline`. It ends the ladder, so `next` moves on, but it is never `completed` and never hands a campaign off. What those stages held moves to history, so its purchase proof stops counting. A passing `qa run --page` leaves QA at this status, and only a completed full QA ends it. Progress snapshots read it as `unknown`.
13
+ - **Reports say what it is.** Doctor warns `qa.baseline_accepted`, and `doctor --ci`, readback and `next` say QA rests on a baseline and no purchase has been tested since. `evidence_debt.qa` gains `baseline`, and the Assembly Report gains a top-level `commerce_baseline`, which keeps the status, warnings and skip reason of what it replaced.
14
+
15
+ ## [1.65.0+agent.1] - 2026-11-07
16
+
17
+ Commercial parity now checks `package.price` as well as `bundle.*.price`. Starter upsells use `package.price`, so a funnel could pass every calculate scenario with no printed price compared. No pin change is needed to read verdicts; the behavior ships in the next release.
18
+
19
+ ### Changed
20
+
21
+ - **`package.price` and `package.<ref>.price` are compared as rendered.** Under `qa run --browser`, each visible node the SDK has filled is compared with calculate's `unit_price` for that package's row on the page. The source holds a template placeholder (`$9.99` on a starter upsell), so the source text is never compared. A difference is the existing warn-only `price-claim-mismatch`. That includes a page printing `package.price`, the list price, beside a live offer.
22
+ - **What cannot be compared is named.** A run without `--browser`, a rendered page that showed none of the claims, a visible node the SDK never wrote, or a claim with no package id, no single exact row or unreadable text each writes a `commercial-parity:incomplete:<code>` warn row and never a mismatch. A run without `--browser` on a page with these claims now reads `incomplete`, so it stays at `ready_with_exceptions`.
23
+ - `verdict.commercial.package_price_claims` counts these claims, and they count toward `extracted_price_claims`, `compared_price_claims` and `unresolved_price_claims`. Journey rows from the `./commercial-journey` export carry `unit_price` when calculate sends it, and captures from `./commercial-parity` carry `package_price_claims`.
24
+
25
+ ## [1.65.0] - 2026-11-07
26
+
27
+ A QA run whose commercial parity stopped short no longer reads as a clean result. Commercial status `incomplete` now keeps the verdict at `ready_with_exceptions` and names the cause and the fix in `exceptions[]`. It stays warn-only: nothing new blocks, and the exit code does not change. To take it, pin `@nextcommerce/campaigns-os@1.65.0` and refresh the installed skills to `1.65.0+skills.1`.
28
+
29
+ ### Changed
30
+
31
+ - **Incomplete commercial coverage is an exception.** A spec with no `campaign.campaigns_api_key`, for example, used to run 0 of its calculate scenarios and could still report `ready`, with the gap visible only under `verdict.commercial`. The verdict is now `ready_with_exceptions`, with a warn row `commercial-parity:incomplete:missing_campaigns_api_key` whose `actual` says what was not compared and how to fix it.
32
+ - A cause an earlier one already explains writes no second row, so a missing key does not also report every page as unpriced. Read the row for what to do: the set of codes is not a contract.
33
+ - Not every cause has a repair. A store coupon, two bound prices on one page, a run in a currency other than the Map's and a funnel over the check's limits say what this run did not compare, and these runs stay at `ready_with_exceptions`.
34
+ - The committed sidecar keeps the row's id, which names the cause. The verdict's `commercial` section gains `incomplete_reasons` with the same causes, and `qa run` prints them under its commercial line.
35
+ - A run that was `ready` only because the gap was invisible now records its QA stage as `completed_with_warnings`, and its Run Record lists `pricing` among its gap classes. Both dispositions still end a run session.
36
+ - **A crash in the commercial check is reported the same way.** It used to leave status `not_run` and the verdict untouched, with a line on stderr. It now reads `incomplete` with the cause `commercial_runner_error`. Runs a gate blocks keep `not_run`, since those verdicts are already `blocked`.
37
+ - The verdict schema's `disposition` and `commercial.status` descriptions say the above, and it documents `commercial.incomplete_reasons`.
38
+
39
+ ## [1.64.0] - 2026-11-07
40
+
41
+ Developers changing a live funnel from a fresh clone can now get going, and the reports say what they compared. To take it, pin `@nextcommerce/campaigns-os@1.64.0`, run `spec fetch` once on each repository set up from a saved Map, and commit the file it writes. These come from the first post-handoff trial on #716.
42
+
43
+ ### Added
44
+
45
+ - **`spec fetch` (#716).** `campaigns-os spec fetch --packet <packet>` restores a saved-Map packet's CampaignSpec on a checkout that lacks it, by the same fetch intake makes. It never replaces a copy on disk, because that copy can hold `spec derive` edits the Map doesn't have, and it doesn't write the packet or the records. Its `bound` result says whether the copy matches the spec the records were made against. A doctor `spec.local_path` error for a missing Map copy now names the command.
46
+ - **`built_output.absent` doctor warning (#716).** When the build stage is complete and `_site/<route>/` isn't there, doctor says the records were not compared with this checkout, and `next` says so at `done`. A fresh clone used to read as finished however much source had moved.
47
+
48
+ ### Changed
49
+
50
+ - **The fetched CampaignSpec is committed (#716).** `.campaign-runtime/fetched-specs/` leaves the managed `.gitignore` block, and an existing block loses the line on the next setup command or `spec fetch`. The committed records are bound to this file and `spec derive` writes into it, so another machine can't rebuild it from the Map once it has been derived.
51
+ - **`doctor --ci` names what it compared (#716).** Each owed stage's report line gives the build it last checked, with the date when QA completed. When the latest change alone is page-only but an earlier rebuild since that check wasn't, the report names that rebuild, so it no longer seems to contradict `record build`. `evidence_debt.<stage>.change` gains `commerce_rebuild`, and `evidence_debt.qa` gains `reviewed_at` when QA completed, each only when there is a value.
52
+ - **Clearer wording for a pre-1.62 build (#716).** `commerce_signature_unavailable` now reads as a build from before the markup hashes existed.
53
+
54
+ ### Fixed
55
+
56
+ - **The docs on pre-1.62 records (#716).** They said such a change reads as `unknown` until `record build` records a signature. It reads as `commerce_affecting`, and `record build` doesn't clear it. Page-only changes start after one full QA on 1.62.0 or later, and the docs now say so.
57
+
58
+ ## [1.63.0+agent.1] - 2026-11-02
59
+
60
+ The `PagePackage` type accepts `prices: null`, as the schemas and the `PackageCurrencyPrices` rule already did. No pin change is needed to read specs; TypeScript callers that pass `null` stop failing type checks on the next release.
61
+
62
+ ### Fixed
63
+
64
+ - `PagePackage.prices` is typed `PackageCurrencyPrice[] | null`, like the package's other nullable fields. Schemas v4 and v5 allow `null`, and the rule treats it as absent, so a spec that validated could fail to type-check. (#714)
65
+ - The authoring guide's `prices[]` section says to refresh the Map after any price change: `spec derive` refreshes `price` and `price_retail` but not `prices[]`, so a default-currency change otherwise leaves `price` and that currency's `prices[]` entry disagreeing.
66
+
67
+ ## [1.63.0] - 2026-10-31
68
+
69
+ CampaignSpec can carry a page package's price in each currency the campaign sells in, and validation warns when a package has no price in one of them. To take it, pin `@nextcommerce/campaigns-os@1.63.0` and refresh the installed skills to `1.63.0+skills.1`; the skill text changes only in its stated revision.
70
+
71
+ ### Added
72
+
73
+ - **`prices[]` on a page package (#714).** A row in `funnels[].pages[].packages[]` may carry `prices: [{ currency, price, price_retail?, price_recurring? }]`, one entry per currency the Campaigns App prices the package in, read from the Campaigns API in that currency (`GET /campaigns/?currency=<code>`). `price` and `price_retail` stay the default currency's. The field is optional and additive in schemas v4 and v5 and in the `PagePackage` type (with `PackageCurrencyPrice`), and the authoring guide's Campaign Currencies section shows it.
74
+ - **`PackageCurrencyPrices` rule (#714).** A warning, tagged `fast` and `spec-only`, so `doctor` and Map Builder both run it. For a row that carries `prices[]`, it reports a currency in `campaign.available_currencies` or `campaign.currency` with no entry, since the Campaigns API leaves an unpriced package out of the campaign in that currency; an entry without an ISO 4217 `currency` and a non-negative decimal `price`, or with a malformed `price_retail` or `price_recurring`; and a currency listed twice. A row without `prices[]` (absent or null) is not checked, and malformed campaign currency codes stay with `CampaignCurrencies`.
75
+
5
76
  ## [1.62.0] - 2026-10-30
6
77
 
7
78
  This release makes the path from a Map to a working campaign shorter to start and cheaper to maintain. CampaignSpec validation catches a checkout nothing sells into and malformed currencies, and it carries the starter templates' analytics vendors. One project can hold several funnels. After handoff, `spec derive` keeps the spec in step with the repo's pages and the live campaign. A change to some pages' copy owes QA on those pages only. `qa run --test-order` holds test purchases back from the analytics vendors it can intercept (all of the template vendors except Northbeam), and `qa run --currency` blocks when the currency did not take. To take it, pin `@nextcommerce/campaigns-os@1.62.0` and refresh the installed skills to `1.62.0+skills.1`.
@@ -26,4 +26,5 @@
26
26
  * (very old browsers) gets the shape check only.
27
27
  */
28
28
  import type { Rule } from '../types.ts';
29
+ export declare function isCurrencyCode(value: unknown): value is string;
29
30
  export declare const CampaignCurrencies: Rule;
@@ -34,7 +34,7 @@ function knownIsoCodes() {
34
34
  }
35
35
  return isoCodes;
36
36
  }
37
- function isCurrencyCode(value) {
37
+ export function isCurrencyCode(value) {
38
38
  if (typeof value !== 'string' || !CODE_SHAPE.test(value))
39
39
  return false;
40
40
  const known = knownIsoCodes();
@@ -42,6 +42,7 @@ export { PackagePricingSanity } from './package-pricing-sanity.ts';
42
42
  export { ShippingMethodsPresent } from './shipping-methods-present.ts';
43
43
  export { ShippingCountriesShape } from './shipping-countries-shape.ts';
44
44
  export { CampaignCurrencies } from './campaign-currencies.ts';
45
+ export { PackageCurrencyPrices } from './package-currency-prices.ts';
45
46
  export { OfferRefIntegrity } from './offer-ref-integrity.ts';
46
47
  export { ExitIntentValidation } from './exit-intent-validation.ts';
47
48
  export { PromoCodeInputValidation } from './promo-code-input-validation.ts';
@@ -41,6 +41,7 @@ import { CampaignMetadata } from "./campaign-metadata.js";
41
41
  import { ShippingMethodsPresent } from "./shipping-methods-present.js";
42
42
  import { ShippingCountriesShape } from "./shipping-countries-shape.js";
43
43
  import { CampaignCurrencies } from "./campaign-currencies.js";
44
+ import { PackageCurrencyPrices } from "./package-currency-prices.js";
44
45
  import { PackagePricingSanity } from "./package-pricing-sanity.js";
45
46
  import { OfferRefIntegrity } from "./offer-ref-integrity.js";
46
47
  import { ExitIntentValidation } from "./exit-intent-validation.js";
@@ -78,6 +79,7 @@ export { PackagePricingSanity } from "./package-pricing-sanity.js";
78
79
  export { ShippingMethodsPresent } from "./shipping-methods-present.js";
79
80
  export { ShippingCountriesShape } from "./shipping-countries-shape.js";
80
81
  export { CampaignCurrencies } from "./campaign-currencies.js";
82
+ export { PackageCurrencyPrices } from "./package-currency-prices.js";
81
83
  export { OfferRefIntegrity } from "./offer-ref-integrity.js";
82
84
  export { ExitIntentValidation } from "./exit-intent-validation.js";
83
85
  export { PromoCodeInputValidation } from "./promo-code-input-validation.js";
@@ -125,6 +127,7 @@ export const allRules = [
125
127
  ShippingMethodsPresent,
126
128
  ShippingCountriesShape,
127
129
  CampaignCurrencies,
130
+ PackageCurrencyPrices,
128
131
  OfferRefIntegrity,
129
132
  ExitIntentValidation,
130
133
  PromoCodeInputValidation,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * PackageCurrencyPrices — checks each page package's price in every currency
3
+ * the campaign sells in (#714).
4
+ *
5
+ * A package row may carry `prices[]`: one `{ currency, price, price_retail?,
6
+ * price_recurring? }` per currency the Campaigns App has a price for, read
7
+ * from the Campaigns API (`GET /campaigns/?currency=<code>`). That read
8
+ * returns only the packages priced in the requested currency, so a package
9
+ * with no price in a listed currency is missing from the campaign for a
10
+ * shopper paying in it.
11
+ *
12
+ * 1. `prices`, when present, is an array of entries, each with a
13
+ * three-letter upper-case ISO 4217 `currency` and a non-negative
14
+ * numeric `price`, and no currency twice.
15
+ * 2. A row with `prices` has an entry for every currency in
16
+ * campaign.available_currencies and for campaign.currency.
17
+ *
18
+ * A row without `prices` (absent or null) is silent: older Maps and
19
+ * hand-written specs carry the default currency's price only (`price`), and
20
+ * nothing is known about the others. Every finding is a warning, since a
21
+ * missing price is fixed in the Campaigns App, not in the spec.
22
+ *
23
+ * The selling currencies are the spec's own `campaign.currency` and
24
+ * `campaign.available_currencies`. The Campaigns App API's
25
+ * `additional_currencies` is not a CampaignSpec field (CampaignCurrencies), so
26
+ * it is not read here either.
27
+ */
28
+ import type { Rule } from '../types.ts';
29
+ export declare const PackageCurrencyPrices: Rule;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * PackageCurrencyPrices — checks each page package's price in every currency
3
+ * the campaign sells in (#714).
4
+ *
5
+ * A package row may carry `prices[]`: one `{ currency, price, price_retail?,
6
+ * price_recurring? }` per currency the Campaigns App has a price for, read
7
+ * from the Campaigns API (`GET /campaigns/?currency=<code>`). That read
8
+ * returns only the packages priced in the requested currency, so a package
9
+ * with no price in a listed currency is missing from the campaign for a
10
+ * shopper paying in it.
11
+ *
12
+ * 1. `prices`, when present, is an array of entries, each with a
13
+ * three-letter upper-case ISO 4217 `currency` and a non-negative
14
+ * numeric `price`, and no currency twice.
15
+ * 2. A row with `prices` has an entry for every currency in
16
+ * campaign.available_currencies and for campaign.currency.
17
+ *
18
+ * A row without `prices` (absent or null) is silent: older Maps and
19
+ * hand-written specs carry the default currency's price only (`price`), and
20
+ * nothing is known about the others. Every finding is a warning, since a
21
+ * missing price is fixed in the Campaigns App, not in the spec.
22
+ *
23
+ * The selling currencies are the spec's own `campaign.currency` and
24
+ * `campaign.available_currencies`. The Campaigns App API's
25
+ * `additional_currencies` is not a CampaignSpec field (CampaignCurrencies), so
26
+ * it is not read here either.
27
+ */
28
+ import { isCurrencyCode } from "./campaign-currencies.js";
29
+ // A non-negative amount: a finite number, or a plain decimal string ("49.99").
30
+ // Hex, binary and exponent strings ("0x10", "1e3") are not prices.
31
+ function isPrice(value) {
32
+ if (typeof value === 'number')
33
+ return Number.isFinite(value) && value >= 0;
34
+ return typeof value === 'string' && /^\d+(\.\d+)?$/.test(value.trim());
35
+ }
36
+ // An optional amount: absent, null, or a price.
37
+ function isOptionalPrice(value) {
38
+ return value === undefined || value === null || isPrice(value);
39
+ }
40
+ // What is wrong with one prices[] entry, or null when it is well formed.
41
+ function entryDefect(entry) {
42
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
43
+ return 'is not a { currency, price } object';
44
+ const row = entry;
45
+ const defects = [];
46
+ if (!isCurrencyCode(row.currency))
47
+ defects.push('a three-letter upper-case ISO 4217 currency');
48
+ if (!isPrice(row.price))
49
+ defects.push('a non-negative decimal price');
50
+ for (const field of ['price_retail', 'price_recurring']) {
51
+ if (!isOptionalPrice(row[field]))
52
+ defects.push(`a non-negative decimal ${field} (or none)`);
53
+ }
54
+ return defects.length ? `needs ${defects.join(' and ')}` : null;
55
+ }
56
+ // The currencies the campaign sells in: the valid listed codes and a valid
57
+ // default. Malformed codes are CampaignCurrencies' to report.
58
+ function sellingCurrencies(spec) {
59
+ const campaign = spec.campaign;
60
+ if (!campaign || typeof campaign !== 'object')
61
+ return [];
62
+ const listed = Array.isArray(campaign.available_currencies) ? campaign.available_currencies : [];
63
+ return [...new Set([campaign.currency, ...listed].filter(isCurrencyCode))];
64
+ }
65
+ export const PackageCurrencyPrices = {
66
+ id: 'PackageCurrencyPrices',
67
+ severity: 'warning',
68
+ tags: ['fast', 'spec-only'],
69
+ check(spec) {
70
+ const violations = [];
71
+ const selling = sellingCurrencies(spec);
72
+ spec.funnels.forEach((funnel, funnelIdx) => {
73
+ ;
74
+ (funnel.pages ?? []).forEach((page, pageIdx) => {
75
+ ;
76
+ (page.packages ?? []).forEach((pkg, pkgIdx) => {
77
+ const prices = pkg.prices;
78
+ if (prices === undefined || prices === null)
79
+ return;
80
+ const path = `/funnels/${funnelIdx}/pages/${pageIdx}/packages/${pkgIdx}/prices`;
81
+ const name = pkg.name || pkg.ref_id;
82
+ const label = `"${page.label || page.id}" — Package "${name}"`;
83
+ if (!Array.isArray(prices)) {
84
+ violations.push({
85
+ ruleId: 'PackageCurrencyPrices',
86
+ severity: 'warning',
87
+ message: `${label}: prices must be an array of { currency, price } entries.`,
88
+ path,
89
+ data: { check: 'prices_shape', pageId: page.id, ref_id: pkg.ref_id, received: prices },
90
+ });
91
+ return;
92
+ }
93
+ const seen = new Set();
94
+ prices.forEach((entry, index) => {
95
+ const currency = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry.currency : undefined;
96
+ const defect = entryDefect(entry);
97
+ // A malformed entry with a valid currency still counts that
98
+ // currency as listed: the entry's own finding names the fix, and a
99
+ // second "has no EUR price" finding would report it twice.
100
+ if (defect) {
101
+ violations.push({
102
+ ruleId: 'PackageCurrencyPrices',
103
+ severity: 'warning',
104
+ message: `${label}: prices[${index}] ${defect}.`,
105
+ path: `${path}/${index}`,
106
+ data: { check: 'price_entry_shape', pageId: page.id, ref_id: pkg.ref_id, received: entry },
107
+ });
108
+ }
109
+ else if (isCurrencyCode(currency) && seen.has(currency)) {
110
+ violations.push({
111
+ ruleId: 'PackageCurrencyPrices',
112
+ severity: 'warning',
113
+ message: `${label}: prices lists ${currency} more than once.`,
114
+ path: `${path}/${index}`,
115
+ data: { check: 'price_currency_repeated', pageId: page.id, ref_id: pkg.ref_id, currency },
116
+ });
117
+ }
118
+ if (isCurrencyCode(currency))
119
+ seen.add(currency);
120
+ });
121
+ const missing = selling.filter((code) => !seen.has(code));
122
+ if (missing.length > 0) {
123
+ const list = missing.length === 1 ? missing[0] : `${missing.slice(0, -1).join(', ')} or ${missing[missing.length - 1]}`;
124
+ violations.push({
125
+ ruleId: 'PackageCurrencyPrices',
126
+ severity: 'warning',
127
+ message: `${label} has no ${list} price, so the campaign leaves it out in ${missing.length === 1 ? 'that currency' : 'those currencies'}. Add the price in the Campaigns App, then refresh the Map.`,
128
+ path,
129
+ data: { check: 'price_currency_missing', pageId: page.id, ref_id: pkg.ref_id, missing },
130
+ });
131
+ }
132
+ });
133
+ });
134
+ });
135
+ return violations;
136
+ },
137
+ };
@@ -108,6 +108,14 @@ export interface PageOffer {
108
108
  benefit?: OfferBenefit;
109
109
  [key: string]: unknown;
110
110
  }
111
+ /** One currency's price for a page package (#714). */
112
+ export interface PackageCurrencyPrice {
113
+ /** Three-letter upper-case ISO 4217 code. */
114
+ currency: string;
115
+ price: number | string;
116
+ price_retail?: number | string | null;
117
+ price_recurring?: number | string | null;
118
+ }
111
119
  export interface PagePackage {
112
120
  ref_id?: string | number;
113
121
  /**
@@ -118,6 +126,14 @@ export interface PagePackage {
118
126
  name?: string | null;
119
127
  price?: number | string | null;
120
128
  price_retail?: number | string | null;
129
+ /**
130
+ * The package's price in each currency the Campaigns App prices it in,
131
+ * read from the Campaigns API (`GET /campaigns/?currency=<code>`). `price`
132
+ * and `price_retail` above stay the default currency's. Optional; a row
133
+ * that carries it should list every currency the campaign sells in
134
+ * (PackageCurrencyPrices).
135
+ */
136
+ prices?: PackageCurrencyPrice[] | null;
121
137
  product_name?: string | null;
122
138
  product_sku?: string | null;
123
139
  product_variant_name?: string | null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@nextcommerce/campaigns-os",
3
- "version": "1.62.0",
3
+ "version": "1.66.0",
4
4
  "status": "developer-preview",
5
5
  "contracts": {
6
6
  "campaign_spec": "4.2-5.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "campaigns-os-effects/v1",
3
3
  "_note": "The declared effect of every supported invocation: what it writes, what it sends, and the test case that proves it. One row per command, per subcommand and per effect-changing flag (vocabulary.effect_changing_flags). A row is not publishable without its effect test — scripts/check-effects.mjs refuses one, and refuses a flag from that list that a help usage line carries and no row declares. Read this with docs/effects.md.",
4
- "contract_version": "1.3.0",
4
+ "contract_version": "1.5.0",
5
5
  "vocabulary": {
6
6
  "annotations": {
7
7
  "readOnlyHint": "The invocation changes nothing: no file under the target, the working directory or the user's machine, and no request off the machine. The command-lifecycle journal counts — a journal append is a write like any other, so every readOnlyHint: true row is an invocation the CLI exempts from lifecycle capture (the converse does not hold: some journal-exempt invocations, such as demo and the --no-write forms, still write other declared files).",
@@ -356,6 +356,67 @@
356
356
  "test_scope": "full",
357
357
  "notes": "page-kit sync --dry-run runs every check the real invocation runs and prints what it would do; it writes nothing under the target, appends no lifecycle entry (the command is declared `dryRun` in src/invocation.mjs) and runs no stale-session closeout."
358
358
  },
359
+ {
360
+ "command": "spec",
361
+ "subcommand": "fetch",
362
+ "flags": [],
363
+ "annotations": {
364
+ "readOnlyHint": false,
365
+ "destructiveHint": false,
366
+ "openWorldHint": true,
367
+ "idempotentHint": true
368
+ },
369
+ "tier": "A",
370
+ "writes": [
371
+ {
372
+ "path": "{target}/.campaign-runtime/fetched-specs/*.json",
373
+ "when": "the packet's saved-Map copy is not on this checkout; a copy already there is never replaced and nothing is sent",
374
+ "observed_in": [
375
+ "no_session",
376
+ "ambient_session",
377
+ "stale_session",
378
+ "lifecycle_log",
379
+ "persisted_consent"
380
+ ]
381
+ },
382
+ {
383
+ "path": "{target}/.gitignore",
384
+ "when": "the command succeeds and the managed block is missing, lacks an entry the toolkit requires, or still lists fetched-specs/",
385
+ "observed_in": [
386
+ "no_session",
387
+ "stale_session",
388
+ "lifecycle_log",
389
+ "persisted_consent"
390
+ ]
391
+ },
392
+ {
393
+ "path": "{lifecycle-journal}",
394
+ "when": "a lifecycle journal is selected for the invocation — an active run session, --lifecycle-journal, or CAMPAIGNS_OS_LIFECYCLE_LOG",
395
+ "observed_in": [
396
+ "ambient_session",
397
+ "lifecycle_log"
398
+ ]
399
+ }
400
+ ],
401
+ "sends": [
402
+ {
403
+ "destination": "{proxy-base}/api/spec/{map-id}",
404
+ "what": "the CampaignSpec fetch",
405
+ "when": "the packet's saved-Map copy is not on this checkout",
406
+ "requires_consent": false,
407
+ "observed_in": [
408
+ "no_session",
409
+ "ambient_session",
410
+ "stale_session",
411
+ "lifecycle_log",
412
+ "persisted_consent"
413
+ ]
414
+ }
415
+ ],
416
+ "effect_test": "effects: spec fetch",
417
+ "test_scope": "full",
418
+ "notes": "Fills a gap only: the copy on disk can hold spec derive edits the Map does not. The packet and the records are not written; a Map that has moved since they were made is reported as bound: differs."
419
+ },
359
420
  {
360
421
  "command": "spec",
361
422
  "subcommand": "derive",
@@ -6214,6 +6275,73 @@
6214
6275
  "test_scope": "full",
6215
6276
  "notes": "record brief --dry-run runs every check the real invocation runs and prints the outcome and the files it would write; it writes nothing (no packet, brief, Build Context, report or stale stamp), takes no target lock, appends no lifecycle entry (the command is declared `dryRun` in src/invocation.mjs) and runs no stale-session closeout."
6216
6277
  },
6278
+ {
6279
+ "command": "record",
6280
+ "subcommand": "baseline",
6281
+ "flags": [],
6282
+ "annotations": {
6283
+ "readOnlyHint": false,
6284
+ "destructiveHint": true,
6285
+ "openWorldHint": false,
6286
+ "idempotentHint": false
6287
+ },
6288
+ "tier": "C",
6289
+ "writes": [
6290
+ {
6291
+ "path": "{runtime}/assembly-report.json",
6292
+ "when": "the record passes every check — stages.polish and stages.qa become baseline_accepted with what they held moved to history, and commerce_baseline names who accepted which build",
6293
+ "observed_in": [
6294
+ "no_session",
6295
+ "ambient_session",
6296
+ "stale_session",
6297
+ "lifecycle_log",
6298
+ "persisted_consent"
6299
+ ]
6300
+ },
6301
+ {
6302
+ "path": "{runtime}/doctor-output.json",
6303
+ "when": "a retained doctor sidecar exists — the record postdates the doctor snapshot, so it is stamped stale",
6304
+ "observed_in": [
6305
+ "no_session",
6306
+ "ambient_session",
6307
+ "stale_session",
6308
+ "lifecycle_log",
6309
+ "persisted_consent"
6310
+ ]
6311
+ },
6312
+ {
6313
+ "path": "{lifecycle-journal}",
6314
+ "when": "a lifecycle journal is selected for the invocation — an active run session, --lifecycle-journal, or CAMPAIGNS_OS_LIFECYCLE_LOG",
6315
+ "observed_in": [
6316
+ "ambient_session",
6317
+ "lifecycle_log"
6318
+ ]
6319
+ }
6320
+ ],
6321
+ "sends": [],
6322
+ "effect_test": "effects: record baseline",
6323
+ "test_scope": "full",
6324
+ "notes": "One time per funnel, for a handed-off funnel whose records predate markup hashes. Tests nothing and sends nothing. It replaces the live Polish and QA records, so any purchase proof they held stops counting; history keeps them."
6325
+ },
6326
+ {
6327
+ "command": "record",
6328
+ "subcommand": "baseline",
6329
+ "flags": [
6330
+ "--dry-run"
6331
+ ],
6332
+ "annotations": {
6333
+ "readOnlyHint": true,
6334
+ "destructiveHint": false,
6335
+ "openWorldHint": false,
6336
+ "idempotentHint": true
6337
+ },
6338
+ "tier": "none",
6339
+ "writes": [],
6340
+ "sends": [],
6341
+ "effect_test": "effects: record baseline --dry-run",
6342
+ "test_scope": "full",
6343
+ "notes": "record baseline --dry-run runs every check the real invocation runs and prints the files it would write; it writes nothing, takes no target lock, appends no lifecycle entry (the command is declared `dryRun` in src/invocation.mjs) and runs no stale-session closeout."
6344
+ },
6217
6345
  {
6218
6346
  "command": "record",
6219
6347
  "subcommand": "spec",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -126,6 +126,7 @@
126
126
  "campaign-spec/rules/index.ts",
127
127
  "campaign-spec/rules/offer-ref-integrity.ts",
128
128
  "campaign-spec/rules/one-checkout-per-path.ts",
129
+ "campaign-spec/rules/package-currency-prices.ts",
129
130
  "campaign-spec/rules/package-pricing-sanity.ts",
130
131
  "campaign-spec/rules/page-count.ts",
131
132
  "campaign-spec/rules/page-id-uniqueness.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": 92,
5
+ "expected_file_count": 94,
6
6
  "states": [
7
7
  {
8
8
  "id": "healthy",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -129,6 +129,7 @@
129
129
  "campaign-spec/rules/index.ts",
130
130
  "campaign-spec/rules/offer-ref-integrity.ts",
131
131
  "campaign-spec/rules/one-checkout-per-path.ts",
132
+ "campaign-spec/rules/package-currency-prices.ts",
132
133
  "campaign-spec/rules/package-pricing-sanity.ts",
133
134
  "campaign-spec/rules/page-count.ts",
134
135
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",
@@ -131,6 +131,7 @@
131
131
  "campaign-spec/rules/index.ts",
132
132
  "campaign-spec/rules/offer-ref-integrity.ts",
133
133
  "campaign-spec/rules/one-checkout-per-path.ts",
134
+ "campaign-spec/rules/package-currency-prices.ts",
134
135
  "campaign-spec/rules/package-pricing-sanity.ts",
135
136
  "campaign-spec/rules/page-count.ts",
136
137
  "campaign-spec/rules/page-id-uniqueness.ts",