@nextcommerce/campaigns-os 1.61.0 → 1.63.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 (129) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +3 -0
  22. package/campaign-spec/dist/rules/index.js +9 -0
  23. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  24. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  25. package/campaign-spec/dist/types.d.ts +59 -9
  26. package/compatibility.json +1 -1
  27. package/contracts/commerce-surface-catalog.json +59 -53
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
  44. package/contracts/release-ledger.json +1260 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
  46. package/contracts/supported-surface.json +8 -8
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/build-packet.md +430 -37
  49. package/docs/campaign-standardization-report.md +4 -4
  50. package/docs/campaigns-os-build-flow.md +54 -3
  51. package/docs/campaignspec-authoring-examples.md +59 -4
  52. package/docs/design-source-package.md +18 -6
  53. package/docs/effects.md +17 -9
  54. package/docs/local-setup.md +1 -1
  55. package/docs/orientation-contract-reference.md +1 -1
  56. package/docs/polish-evidence.md +2 -0
  57. package/docs/progress-snapshots.md +7 -4
  58. package/docs/qa-and-test-orders.md +147 -1
  59. package/docs/readback.md +40 -5
  60. package/docs/runtime-readiness.md +7 -3
  61. package/docs/sdk-storage-compatibility.md +1 -1
  62. package/docs/skills-revision.md +10 -10
  63. package/package.json +1 -1
  64. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  65. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  66. package/schemas/campaign-spec.v4.schema.json +34 -3
  67. package/schemas/campaign-spec.v5.schema.json +34 -3
  68. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  69. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  70. package/skills/campaign-readback-classification/SKILL.md +3 -3
  71. package/skills/campaign-run-evidence/SKILL.md +3 -3
  72. package/skills/contribution-intake/SKILL.md +3 -3
  73. package/skills/next-campaigns-build/SKILL.md +4 -3
  74. package/skills/next-campaigns-os/SKILL.md +6 -4
  75. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  76. package/skills/next-campaigns-polish/SKILL.md +5 -4
  77. package/skills/next-campaigns-qa/SKILL.md +7 -4
  78. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  79. package/skills.json +11 -11
  80. package/src/brand-theme.mjs +2 -1
  81. package/src/build-brief.mjs +4 -1
  82. package/src/campaign-workspace.mjs +288 -15
  83. package/src/change-scope.mjs +284 -0
  84. package/src/cli.mjs +308 -86
  85. package/src/commercial-journey.mjs +16 -3
  86. package/src/commercial-parity.mjs +15 -7
  87. package/src/content-residue.mjs +4 -2
  88. package/src/design-source-package.mjs +36 -0
  89. package/src/design-source-publication.mjs +9 -6
  90. package/src/directory-lock.mjs +73 -19
  91. package/src/doctor/checks.mjs +79 -7
  92. package/src/doctor/ci.mjs +75 -10
  93. package/src/doctor/inspect.mjs +23 -6
  94. package/src/doctor/next-step.mjs +7 -0
  95. package/src/doctor-sidecar.mjs +3 -2
  96. package/src/evidence-debt.mjs +47 -7
  97. package/src/finding-cause.mjs +12 -11
  98. package/src/input-refresh.mjs +339 -32
  99. package/src/invocation.mjs +7 -1
  100. package/src/live-campaign-refs.mjs +7 -2
  101. package/src/local-preview-policy.mjs +35 -2
  102. package/src/page-kit-analytics-vendors.mjs +132 -0
  103. package/src/polish-browser.mjs +3 -1
  104. package/src/polish-readability.mjs +221 -9
  105. package/src/polish-visual.mjs +9 -2
  106. package/src/progress-node.mjs +3 -2
  107. package/src/qa-analytics-parity.mjs +17 -1
  108. package/src/qa-browser.mjs +40 -4
  109. package/src/qa-commercial-parity.mjs +50 -3
  110. package/src/qa-node.mjs +196 -16
  111. package/src/qa-publish.mjs +9 -8
  112. package/src/qa-sidecar.mjs +16 -4
  113. package/src/qa-vendor-beacons.mjs +232 -0
  114. package/src/qa-vendor-endpoints.mjs +306 -0
  115. package/src/qc-accept.mjs +29 -1
  116. package/src/qc-check-registry.mjs +5 -1
  117. package/src/qc-results.mjs +73 -26
  118. package/src/readback.mjs +58 -10
  119. package/src/route-identity.mjs +10 -0
  120. package/src/run-record.mjs +18 -10
  121. package/src/runtime-state-ignore.mjs +6 -2
  122. package/src/sdk-storage-compatibility.mjs +21 -2
  123. package/src/source-html-intake.mjs +15 -1
  124. package/src/spec-derive-campaign.mjs +216 -0
  125. package/src/spec-derive.mjs +473 -53
  126. package/src/spec-identity.mjs +14 -0
  127. package/src/stage-ledger.mjs +40 -1
  128. package/src/stage-record.mjs +58 -13
  129. package/src/target-lock.mjs +11 -1
@@ -81,10 +81,10 @@ field before attributing the file.
81
81
  `--template-family`), `--slug`, `--sdk-support-policy`, `--field-contract`,
82
82
  `--no-doctor`, `--json`, and the two flags every command accepts,
83
83
  `--run-id` and `--lifecycle-journal`. Any other flag is refused before the
84
- scan starts, with the known list in the message — including the
85
- `--flag=value` spelling, which the parser would otherwise store as an unknown
86
- key (`--no-doctor=maybe` used to run the doctor anyway). Values follow the
87
- flag as the next argument.
84
+ scan starts, with the known list in the message. Values follow the flag as
85
+ the next argument: like every Campaigns OS command, standardize refuses the
86
+ `--flag=value` spelling before the scan starts (`--no-doctor=maybe` used to
87
+ run the doctor anyway).
88
88
 
89
89
  ### Exit codes
90
90
 
@@ -33,8 +33,8 @@ a production render instead. Environment differences also change fingerprints.
33
33
 
34
34
  | CI status | Exit code | Meaning |
35
35
  |---|---|---|
36
- | `current` | `0` | Polish and QA both bind to the build on disk and neither is lifecycle-required. |
37
- | `owed` | `3` | Polish or QA binds to another build or is lifecycle-required; evidence is owed. |
36
+ | `current` | `0` | Polish and QA both bind to the build on disk and neither is lifecycle-required, or the only owed stage is advisory Polish for a page-only change. |
37
+ | `owed` | `3` | Polish or QA binds to another build or is lifecycle-required; evidence is owed. For a page-only change only QA on the changed pages counts. |
38
38
  | `unknown` | `4` | Output or assembly fingerprints are missing, a stage fingerprint is missing without a lifecycle requirement, output is unreadable, or inspection throws; debt cannot be determined. |
39
39
  | `usage` | `2` | The packet is missing, unreadable or invalid JSON. |
40
40
 
@@ -53,6 +53,46 @@ The check reads recorded fingerprints, not machine-local capture files.
53
53
  Readback's `evidence_debt` uses the same assessment, showing an agent this debt
54
54
  before it edits; its separate time-based staleness assessment is not the CI signal.
55
55
 
56
+ ### What a change owes
57
+
58
+ Each owed stage carries `change`: what changed since the build that stage
59
+ last reviewed. It is read from the rebuilds `record build` keeps
60
+ (`stages.assembly.build_changes`) plus the drift on disk, so the changed pages
61
+ stay listed after `record build`. A change is one of two kinds:
62
+
63
+ - **Page-only.** Only the HTML of some pages changed (copy, image references,
64
+ inline styling), and none of them is a checkout page or changed its commerce markup:
65
+ `data-next-*`, `os-checkout-*` and other package, offer, shipping or upsell
66
+ attributes, inline event handlers (`onclick` and the like), `next-*` meta
67
+ tags (the upsell accept and decline routes among them), scripts, link
68
+ targets or form actions. A checkout page is one whose type is checkout,
69
+ checkout step or select, or one that carries checkout or payment markup
70
+ (`data-next-checkout*`, `data-next-payment*`, `data-next-express-checkout*`,
71
+ `os-checkout*`). The campaign's own entry in
72
+ `_data/campaigns.json` (SDK pin, analytics ids, store fields) is unchanged.
73
+ It owes QA on the changed pages only: `qa.scope` is `pages` and
74
+ `qa.owed_pages` lists them. Polish is advisory (`polish.advisory: true`): it
75
+ is reported, and `next`, the CI check and the QA gate do not require it.
76
+ - **Commerce-affecting.** Anything else: a checkout page, commerce markup or
77
+ the config entry changed, a page was added or removed (a route change), or a
78
+ built file other than a page's HTML changed (a script, stylesheet or image
79
+ file cannot be mapped to one page). It owes the full Polish and QA, as
80
+ does a change that cannot be read (a record made before `record build`
81
+ kept a commerce signature, or a rebuild history that no longer reaches the
82
+ reviewed build). A Polish or QA requirement a brief or spec change made is
83
+ always full.
84
+
85
+ A page-only change extends only a review that completed. Polish is advisory
86
+ only when the last Polish completed or was skipped; a blocked Polish owes the
87
+ full stage. QA is owed on the changed pages only when the last QA completed,
88
+ or when it was a page-scoped run that did not complete, in which case the
89
+ pages are counted from the full QA that run extended, so its unpaid pages stay
90
+ owed. A blocked or pending full QA owes a full run.
91
+
92
+ The PR comment opens with the kind it found and what that owes, for example
93
+ "Change found: page-only. It owes QA on `upsell1`; Polish is advisory." A
94
+ commerce-affecting change names its reasons.
95
+
56
96
  Copy the [workflow example](https://github.com/NextCommerceCo/campaigns-os/blob/main/docs/examples/campaign-repo-evidence-check.yml) into the
57
97
  campaign repository and adjust the packet path. It posts or updates one PR
58
98
  comment using the fixed `<!-- campaign-evidence-check -->` marker. Evidence
@@ -67,7 +107,18 @@ Fork pull requests may lack comment-write permission; comment failures are
67
107
  nonfatal and the report remains in the job log. The example uses `pull_request`
68
108
  and does not run untrusted code with `pull_request_target` privileges.
69
109
 
70
- To repay fingerprint-drift debt, rebuild and record the output, re-run `polish capture` against
110
+ To repay a page-only change, rebuild, run `record build`, then run
111
+ `qa run --packet <packet> --page <page_id>` against that build, repeating
112
+ `--page` for each changed page the report names. A page-scoped run checks only
113
+ those pages and places no test orders; the QA stage it records keeps the
114
+ purchase proof of the full run it extends. It refuses, before checking
115
+ anything, when the output on disk is not the recorded build, when the change
116
+ is commerce-affecting, when a changed page is left out, when the QA it would
117
+ extend did not complete, or when QA's inputs (the brief's `qa_policy` and the
118
+ CampaignSpec) changed or cannot be confirmed current. Running Polish for a
119
+ page-only change is optional.
120
+
121
+ To repay fingerprint-drift debt for any other change, rebuild and record the output, re-run `polish capture` against
71
122
  that served build, record Polish with `record polish --evidence <evidence.json>`,
72
123
  then run `qa run` to record QA for the current build. For lifecycle-required
73
124
  stages with matching build bindings, re-run the required stages against the
@@ -27,7 +27,9 @@ Authoring guidance:
27
27
  - `packages[]` entries use `ref_id` + `qty` (the real export key is `qty`,
28
28
  not `quantity`). A plain entry is the main package; add-ons carry the
29
29
  boolean role flags `is_order_bump: true` / `is_upsell: true` — there is
30
- no `role` field in exports.
30
+ no `role` field in exports. The flags mean "add-on" on `checkout`,
31
+ `checkout_step` and `select` pages only. On an upsell or downsell page
32
+ they mark nothing (see the upsell page below).
31
33
  - `sdk_hints.template_family` names the intended starter family only as a
32
34
  hint; the Build Packet still locks the family.
33
35
  - `sdk_hints.meta_tags.next-success-url` points to the next runtime route.
@@ -65,9 +67,12 @@ Authoring guidance:
65
67
  - `type: "upsell"` or `type: "downsell"`, with
66
68
  `sdk_hints.sdk_page_type: "upsell"`
67
69
  - `page_url` is a Page Kit route, not a source filename.
68
- - `packages[]` (with `is_upsell: true`) or `offers[]` names the
69
- post-purchase offer refs. Offers are identified by `ref_id` into the
70
- root `offers[]` catalog (`package_ref_id` is not part of the contract).
70
+ - `packages[]` or `offers[]` names the post-purchase offer refs. Write each
71
+ package as a plain `{ ref_id, qty }` entry. On an upsell or downsell page
72
+ every package row is the page's offer and never an order bump, so
73
+ `is_upsell: true` on that row is accepted and changes nothing about
74
+ its role. Offers are identified by `ref_id` into the root `offers[]` catalog
75
+ (`package_ref_id` is not part of the contract).
71
76
  - `on_accept` and `on_decline` hold the next PAGE IDs; route paths live
72
77
  in `page_url` / derived `resolved_routing`.
73
78
 
@@ -91,6 +96,56 @@ Generator checklist:
91
96
  - Keep checkout/upsell/downsell/receipt runtime surfaces template-clone-first;
92
97
  source HTML can own visual composition and copy, not payment/order mutation.
93
98
 
99
+ ## Campaign Currencies
100
+
101
+ - `campaign.currency` is the default currency: a three-letter upper-case
102
+ ISO 4217 code such as `"USD"`. A lower-case code (`"usd"`) or a symbol
103
+ (`"US$"`) is an error, because Campaign Cart can fall back to a cached USD
104
+ campaign and price the page in the wrong currency.
105
+ - `campaign.available_currencies` lists every currency the campaign sells in,
106
+ the default included, as code strings: `["USD", "EUR", "GBP"]`. The
107
+ Campaigns API returns this list as `{ code, label }` objects; Map Builder
108
+ writes the codes, and a hand-written spec should too.
109
+ - The Campaigns App API also has `additional_currencies`, which is the same
110
+ list without the default. It is not a CampaignSpec field: put every
111
+ currency in `available_currencies`.
112
+ - When `available_currencies` is non-empty and does not contain
113
+ `campaign.currency`, the validator warns. An empty list states nothing and
114
+ is not checked.
115
+
116
+ These checks are the `CampaignCurrencies` validator rule, which
117
+ `campaigns-os doctor` runs with the rest of the spec rules.
118
+
119
+ A page package row can also carry its price in each currency, as `prices[]`:
120
+
121
+ ```json
122
+ {
123
+ "ref_id": 101, "qty": 1, "price": "49.99", "price_retail": "79.99",
124
+ "prices": [
125
+ { "currency": "USD", "price": "49.99", "price_retail": "79.99" },
126
+ { "currency": "EUR", "price": "46.99", "price_retail": "74.99" }
127
+ ]
128
+ }
129
+ ```
130
+
131
+ - `price` and `price_retail` stay the default currency's. Each `prices[]`
132
+ entry has a `currency` code and a `price`, plus `price_retail` and, for a
133
+ subscription package, `price_recurring` when the Campaigns App has them.
134
+ - The values come from the Campaigns API read in each currency
135
+ (`GET /campaigns/?currency=EUR`). That read returns only the packages the
136
+ Campaigns App has a price for in that currency, so a package with no EUR
137
+ price is missing from the campaign for a shopper paying in EUR. Map Builder
138
+ fills `prices[]` when it refreshes from the API; set prices in the
139
+ Campaigns App, not in the spec. `campaigns-os spec derive` refreshes `price`
140
+ and `price_retail` from one default-currency read and leaves `prices[]` as
141
+ it was, so after changing a non-default price, refresh the Map in Map
142
+ Builder.
143
+ - `prices[]` is optional. A row without it says nothing about other
144
+ currencies and is not checked. A row with it should list every currency in
145
+ `available_currencies` and `campaign.currency`; the `PackageCurrencyPrices`
146
+ rule warns on a missing currency, a malformed entry and a currency listed
147
+ twice.
148
+
94
149
  ## Order Bump Notes
95
150
 
96
151
  An order bump is still a checkout package/offer surface. It should be encoded as
@@ -439,12 +439,24 @@ left by a process that died is recovered automatically, and a lock directory
439
439
  without an owner record (only an older release leaves one) is never taken
440
440
  over: the command refuses it after about a second and names it. Confirm no
441
441
  campaigns-os process is working on the target, then remove it. Anything else
442
- at the lock path (a file, a symbolic link, or a directory with no owner record
443
- that holds other entries) is not a lock: once the wait ends the command names
444
- what is there and never removes it. The holder of the lock removes staging and
445
- released directories beside it whose recorded owner process no longer exists.
446
- Do not run an older Campaigns OS release against the same target at the same
447
- time. A waiver's `--dry-run` preview takes no lock.
442
+ at the lock path (a file, a symbolic link, a directory the command cannot read,
443
+ or a directory with no owner record that holds other entries) is not a lock:
444
+ once the wait ends the command names what is there and never removes it. The
445
+ holder of the lock removes staging, recovery, released and abandoned
446
+ directories beside it whose recorded owner process no longer exists.
447
+
448
+ Run one Campaigns OS release against a target at a time. Each owner record
449
+ carries a `lock_format` number (the lock layout the writing release uses).
450
+ Releases before 1.43.2 took the lock in two steps, creating the directory and
451
+ then writing its owner, and a later release can replace that directory in the
452
+ gap between them, so both runs enter. A command that finds the lock held by a
453
+ live process whose owner record has no `lock_format`, or a different one,
454
+ refuses at once and names that process instead of waiting for it. Every
455
+ release before `lock_format` existed writes no number, so a holder from one of
456
+ those is refused too, even though only releases before 1.43.2 can race. The
457
+ check only sees a release that already holds the lock: two releases that start
458
+ at the same instant can still meet in that gap. A dead holder from any release
459
+ is recovered as usual. A waiver's `--dry-run` preview takes no lock.
448
460
 
449
461
  Before writing any output, `prepare-build` also requires distinct paths for the
450
462
  Build Packet, Build Context, Assembly Report, Doctor output, normalized Build
package/docs/effects.md CHANGED
@@ -70,10 +70,12 @@ with one of these:
70
70
  | Token | Resolves to |
71
71
  | --- | --- |
72
72
  | `{target}` | The target the invocation names: the directory given to `--target`, or the Page Kit target repository the Build Packet points at. |
73
+ | `{runtime}` | The funnel's runtime directory under `{target}`: `{target}/.campaign-runtime` for a Build Packet that records no `assembly.runtime_dir` (every packet before per-funnel records, and the first funnel of a project), else `{target}/<assembly.runtime_dir>`, which is `.campaign-runtime/funnels/<public_route_slug>` for each later funnel of the same project. The funnel's Build Context, Assembly Report, doctor output, theme output and intake inputs live here. |
73
74
  | `{cwd}` | The working directory the invocation runs in. |
74
75
  | `{spec}` | The CampaignSpec file the Build Packet names (`spec.local_path`), which need not live inside the target repository. |
75
76
  | `{home}` | Your machine: the home directory and the config root under it (`XDG_CONFIG_HOME` when set). |
76
77
  | `{packet}` | The Build Packet the invocation names (`--packet`), wherever it lives — it need not be the copy inside the target repository. |
78
+ | `{packet-runtime}` | The same funnel runtime directory beside the Build Packet: `<packet directory>/.campaign-runtime`, or `<packet directory>/<assembly.runtime_dir>` when the packet records one. The committed QA verdict sidecar and the Run Records live here. |
77
79
  | `{lifecycle-journal}` | The command-lifecycle journal wherever it was selected for this invocation. |
78
80
  | `{proxy-base}` | The endpoint `--proxy-base` names, or the canonical NEXT endpoint when it does not. |
79
81
  | `{base-url}` | The campaign under test, as `--base-url` names it or as the packet derives it. |
@@ -81,8 +83,8 @@ with one of these:
81
83
 
82
84
  The tokens matter because effects are not all under the target. `install-skills`
83
85
  writes your **home** directory, not the campaign. `telemetry on` writes your
84
- **machine** config. `run-record` writes beside the **working directory**, not the
85
- target repo. A row that said "writes the target" would be wrong about all three.
86
+ **machine** config. `run-record` writes beside the **Build Packet**
87
+ (`{packet-runtime}`), not the target repo. A row that said "writes the target" would be wrong about all three.
86
88
 
87
89
  ### The Map Builder fetch
88
90
 
@@ -109,14 +111,14 @@ campaign. No store or Admin credential is used. The proxy's answer is an
109
111
  envelope whose `data` field holds the campaign. A failed read, an `ok: false`
110
112
  envelope, one with an `error` and no campaign, or several campaigns with no
111
113
  `campaign.ref_id` to pick one is recorded as `not_run` with its reason, never
112
- as a pass. `--no-live-refs` on `doctor` or `qa run` skips only this read and
114
+ as a pass. `--no-live-refs` on `doctor`, `qa run` or `spec derive` skips only this read and
113
115
  records `not_run` with reason `disabled`; every other declared write and send
114
116
  is unchanged. Each form has its own row, including `doctor --write
115
117
  --no-live-refs` and `qa run --no-live-refs` with each `qa run` modifier.
116
118
  `doctor --no-write --no-live-refs` has the effects of the `doctor
117
119
  --no-live-refs` row.
118
120
 
119
- Only `doctor` and `qa run` make this read. The other commands that run doctor
121
+ Only `doctor`, `qa run` and `spec derive` (see `spec derive` and the live campaign, below) make this read. The other commands that run doctor
120
122
  internally make no request and record `derived.live_campaign_refs` as
121
123
  `not_run` with reason `not_read`, with no warning: `start`, `prepare-build`
122
124
  and `build` (the intake alias for prepare-build + doctor), `theme waive`,
@@ -130,11 +132,13 @@ itself carries QA's own read).
130
132
 
131
133
  ### `record brief` and `record spec`
132
134
 
133
- **Saving brief answers.** `campaigns-os record brief --packet <packet>` saves Campaign Build Brief answers without re-running `start` or `prepare-build`. It writes the normalized brief under `{target}/.campaign-runtime/input/`, the Build Packet, the build context, the Assembly Report and the doctor-sidecar stale stamp, and sends nothing. It refuses a path that points at one of the files Campaigns OS writes itself, such as the normalized brief. Saving a brief whose content is unchanged keeps all stage evidence and statuses. Reformatting, reordering keys, writing a default value explicitly, or changing only `_meta` also keeps all stage evidence and statuses; the save may rewrite the brief files. When the presentation sections change, build, Polish and QA are owed again. When only `qa_policy` changes, QA alone is owed again. Setup and deploy records are kept. Each replaced record is kept under the stage's `history`; the last five superseded records per stage are kept, and history is never used as current proof.
135
+ **Saving brief answers.** `campaigns-os record brief --packet <packet>` saves Campaign Build Brief answers without re-running `start` or `prepare-build`. It writes the normalized brief under the funnel's runtime directory (`{runtime}/input/`), the Build Packet, the build context, the Assembly Report and the doctor-sidecar stale stamp, and sends nothing. It refuses a path that points at one of the files Campaigns OS writes itself, such as the normalized brief. Saving a brief whose content is unchanged keeps all stage evidence and statuses. Reformatting, reordering keys, writing a default value explicitly, or changing only `_meta` also keeps all stage evidence and statuses; the save may rewrite the brief files. When the presentation sections change, build, Polish and QA are owed again. When only `qa_policy` changes, QA alone is owed again. Setup and deploy records are kept. Each replaced record is kept under the stage's `history`; the last five superseded records per stage are kept, and history is never used as current proof.
134
136
 
135
- **Binding a CampaignSpec change.** `campaigns-os record spec --packet <packet>` binds the new CampaignSpec content: it updates the build context and report identity, re-reads the brief file last saved (by intake or `record brief`), keeps waivers, warning accepts, the applied theme and deploy settings, and moves each superseded stage record into that stage's `history`. It writes the build context, the Assembly Report, the normalized brief and the doctor-sidecar stale stamp; it does not write the Build Packet, and it sends nothing. `spec derive` no longer rebinds identity; run `record spec` after it. A change to the set, order or routes of active pages still needs intake.
137
+ **Binding a CampaignSpec change.** `campaigns-os record spec --packet <packet>` binds the new CampaignSpec content: it updates the build context and report identity, re-reads the brief file last saved (by intake or `record brief`), keeps waivers, warning accepts, the applied theme and deploy settings, and moves each superseded stage record into that stage's `history`. It writes the build context, the Assembly Report, the normalized brief and the doctor-sidecar stale stamp, and it sends nothing. It writes the Build Packet and the Design Source Package only when it binds a page change: a move of routes alone, or on a handed-off campaign (deploy or QA completed) a page added or removed; then a deploy recorded without a build fingerprint is owed again too when the set of built pages changes (a fingerprint-bound deploy is made owed by the next `record build`). A rerun after a run that stopped part way moves the Design Source Package references that run left behind. `spec derive` no longer rebinds identity; run `record spec` after it. A change to the order of active pages, or a page added or removed before handoff, still needs intake.
136
138
 
137
- Both rows are tier C, because a change makes recorded stages owed again, and each `--dry-run` form is tier none: it writes nothing and appends no lifecycle entry. A CampaignSpec change that was never saved with `record spec` is recorded by the next `record build`, `record polish` or QA run, with the same effect; `record build` then also marks Polish and QA owed again. An edit to the brief file is not read until it is saved with `record brief`: until then the build reads the saved brief, and doctor warns `build_brief.input_unsaved`. Intake now also refuses, without `--force`, to discard recorded waivers, warning accepts, stage history, an applied theme, or a recorded deploy target, preview URL, production URL, order-path depth or allowed-domains confirmation; with `--force`, it keeps stage history, archives the build, Polish and QA records it clears, and keeps the record that the inputs changed.
139
+ Both `record` rows are tier C, because a change makes recorded stages owed again, and each `--dry-run` form is tier none: it writes nothing and appends no lifecycle entry. A CampaignSpec change that was never saved with `record spec` is recorded by the next `record build`, `record polish` or QA run, with the same effect; `record build` then also marks Polish and QA owed again. An edit to the brief file is not read until it is saved with `record brief`: until then the build reads the saved brief, and doctor warns `build_brief.input_unsaved`. Intake now also refuses, without `--force`, to discard recorded waivers, warning accepts, stage history, an applied theme, or a recorded deploy target, preview URL, production URL, order-path depth or allowed-domains confirmation; with `--force`, it keeps stage history, archives the build, Polish and QA records it clears, and keeps the record that the inputs changed.
140
+
141
+ **`spec derive` and the live campaign.** Once the campaign is built (its `_site/<route>/` exists) and a public Campaigns API key resolves, `spec derive` makes the same read-only `GET {proxy-base}/api/campaign` doctor makes and refreshes the offer and package fields the spec mirrors from the Campaigns App; `--dry-run` makes it too, so the diff shows those fields. `--no-live-refs` sends nothing (its own row, tier B). `spec derive` and its `--dry-run` are tier A.
138
142
 
139
143
  ## How to read a row
140
144
 
@@ -164,8 +168,9 @@ flags that change what the invocation does to the world are listed once, in
164
168
  `vocabulary.effect_changing_flags`: `--apply`, `--browser`, `--built`, `--dry-run`,
165
169
  `--emit-packet`, `--example`, `--force`, `--from-store`, `--list`,
166
170
  `--no-live-refs`, `--no-post-verdict`, `--no-probe`, `--no-remit`, `--no-run-session`,
167
- `--no-write`, `--republish`, `--test-order`, `--write`, `--write-map`. Flags
168
- that only change the output shape (`--json`, `--report`) deliberately do not.
171
+ `--no-write`, `--republish`, `--test-order`, `--vendor-beacons`, `--write`,
172
+ `--write-map`. Flags that only change the output shape (`--json`, `--report`)
173
+ deliberately do not.
169
174
 
170
175
  **"The help text" is every help block the CLI prints**, not one file's.
171
176
  `campaigns-os qa` prints its own from `src/qa-node.mjs`, and while the coverage
@@ -253,6 +258,9 @@ input resolution. They append no lifecycle entry. Accepted modes remain
253
258
  `accept`, `decline`, and `both` (case-insensitive); browser `--test-order` still
254
259
  takes precedence and does not require the legacy cart. API credentials are
255
260
  still checked only inside the legacy handler and failures there are journaled.
261
+ A `qa run --vendor-beacons` value other than `live`, or `--vendor-beacons` on
262
+ a run without browser `--test-order`, is likewise an argv-only refusal before
263
+ QA input resolution.
256
264
  The nested run-record refusal scope in session closeout guards against future
257
265
  changes. No internal closeout can currently create a refusal before its
258
266
  invoking command journals.
@@ -3,7 +3,7 @@
3
3
  For a new campaign, create an empty working folder and run this from it:
4
4
 
5
5
  ```sh
6
- npm init -y && npm install --save-exact next-campaign-page-kit@0.3.0 && npm install --save-dev --save-exact @nextcommerce/campaigns-os@1.61.0 && npx --no-install campaigns-os tooling setup --target . --platform claude
6
+ npm init -y && npm install --save-exact next-campaign-page-kit@0.3.0 && npm install --save-dev --save-exact @nextcommerce/campaigns-os@1.63.0 && npx --no-install campaigns-os tooling setup --target . --platform claude
7
7
  ```
8
8
 
9
9
  `npm init -y` gives the folder its own `package.json`. Without one, npm
@@ -26,7 +26,7 @@ Ledger schema id: `campaigns-os-release-ledger/v1`
26
26
  Change policy version: `1.0.0`
27
27
  Reason-code vocabulary version: `1.0.0`
28
28
  Limits version: `1.0.0`
29
- Supported surface at generation time: `1.61.0`
29
+ Supported surface at generation time: `1.63.0`
30
30
 
31
31
  ## Forward compatibility
32
32
 
@@ -121,6 +121,8 @@ The size check covers `<img>` elements in the page, including those inside open
121
121
 
122
122
  **Readability.** `polish capture` also measures text contrast on every page under `_site/<slug>/` (up to 128 pages per run), at desktop (1440×1200) and mobile (390×844) widths, device pixel ratio 1, including campaigns whose pages are all template stock: the Polish route plan now covers the mapped pages plus every built page, while the page-load capture keeps its own page set. On a campaign with no mapped pages, `polish capture` measures readability, prints the page-load message as a warning, and exits successfully. The text it reads: purchase, accept and decline controls; submit buttons and submit-type inputs; SDK action controls; bundle cards (names, prices, badges and the card selected at load); order bumps; prices; checkout labels and placeholder hints; and other body text. Ratios follow WCAG 2.x and are compared unrounded: 4.5:1 for normal text, 3:1 for large text (at least 24 CSS px, or at least 18.66 CSS px at weight 700 or more; semibold does not qualify). Colours in any CSS Color 4 form the browser reports are converted to sRGB; values outside sRGB are clipped and marked. Backgrounds are composited through transparent layers. SVG text is measured with its fill colour, and placeholder hints with their own colour, size and weight. Gradients, background images, pseudo-element backgrounds, a `::first-line` or `::first-letter` style that changes the text's colour, background or opacity, overlapping positioned layers or images (and the shapes of an SVG drawing under its text), text clipped to a background, colours that cannot be read (including a text fill colour, and an SVG fill that is not a plain colour or is combined with a stroke), and controls marked disabled only by the SDK's `next-disabled` class produce a review result with a reason and a crop; so do opacity, filters, blend modes and masks on the text or on any element around it. `clip-path` is ignored. Disabled controls (`disabled` or `aria-disabled="true"`) are listed as excluded (inactive control) and are never accepted; `readonly` is not disabled. Polish measures the states present when the page loads — default, selected, active, and open panels — without clicking, scrolling or typing. Text that has not rendered, controls still loading, controls hidden at load, text supplied by CSS `content`, selection states and collapsed panels not present at load, text inside closed shadow roots or cross-origin frames, pages measured before the SDK, stylesheets or fonts were ready, and pages not captured are listed as unexercised. Overlapping layers are detected by box intersection: a layer the page paints over text in another way is not detected. A ratio under the requirement is a warning. Warnings never block Polish or change readiness. The probe reads the page without changing it, and adds at most about five minutes of work per run; pages it could not reach in that time are listed as unexercised. The stored measurements are re-derived on every read, and the page set, widths and campaign are checked against the current build; but the colours the browser reported are tamper-evident only, like other Polish captures. Readability and media weight results read as stale when the built output differs from the recorded build, even before a new `record build`.
123
123
 
124
+ **Layout conventions.** In the same pass as readability, at the mobile width, `polish capture` checks two layout conventions from the `layout_conventions` block of `contracts/template-brand-contract.shared-commerce.v0.json`. Each reports as a `layout.conventions` row, keyed by convention, on every built page. The first, `floating_cta`, covers floating CTA containers. A floating CTA container is the nearest box around a visible link or button whose computed position is `fixed` or `sticky`. Headers, navigation, dialogs, the announcement bar, boxes that hold a visible form field, and boxes taller than half the viewport do not count. Such a container should hold its control alone: one link or button and no other visible text. A container with a second control, or with text outside its control (rendered or CSS-generated), is a `floating_cta_extra_content` warning that names the container, its controls and up to ten of the other text elements. The record keeps the container's box and each control's box. The second, `cta_label_size`, reads the readability record's own measurements. Every rendered, enabled text element of a purchase or accept control or a submit button (readability roles `add_to_cart`, `upsell_accept` and `submit_control`) should be at least 16 CSS px. A smaller one is a `cta_label_too_small` warning with its measured size. A CTA that is a plain link with no SDK action reads as body text in the readability record and is not checked for label size. A page that meets both conventions reads pass. Both warnings can be accepted with `checkpoint accept`; neither blocks Polish, changes readiness, or reads the merchant's copy. A readability record captured before this check holds no layout, and the check reads `not_captured_by_this_version` until the next `polish capture`.
125
+
124
126
  **Element parity.** For each page built from provided source HTML, `polish capture` reads the source HTML render and the built page that the package capture already makes, with the same browser at the same width, height and pixel ratio (desktop 1440×1200 and mobile 390×844). On both it reads every visible element that holds text: its box, its text, its computed font family, size, weight, style, line height, letter spacing and text transform, and its text and background colour. Elements are matched by their text: exact matches that occur once on each side first, then the nearest exact match after removing the page's median offset, then text split across child elements, then close wording (at least 60% of words shared). A design text of four or more characters is reported missing only when it appears nowhere in the built page's visible text; where part of the built page could not be read (very long text, closed components, frames), it is listed for review instead. Differences become warnings when they exceed: 6 px for horizontal position, width and height, and for the vertical gap to the previous matched element (so one inserted band reports once, where the layout changed); 0.6 px font size; 2 px line height; 0.2 px letter spacing; 3 of 255 in any colour channel; any change of font family, weight, style or text transform. A web font that loaded for the source but not for the build is a warning even when the CSS names the same family. The font each glyph was actually drawn with is not read, and the handoff says so. Colour that cannot be measured on either side is listed for review. A source with no text, a source or build that could not be rendered or was not ready in time, a width or pixel-ratio mismatch (never resized), a source file that differs from the one recorded at intake, pages over the run's limits (1 500 elements per page, 32 pages, 200 font faces, about one minute of probing), and pages with unread parts are listed as unexercised or for review, never as a match. The probe's time counts against the package capture's own time budget. Both measurements are saved as local, git-ignored files, hashed and bound to the record. That makes an edit to part of the evidence detectable, but a process that rewrites the files and both records consistently is not detected. The source is measured again on every capture, so changed stylesheets or fonts are always seen; an unchanged source produces the same saved file. Warnings can be accepted with `checkpoint accept` and lapse when the measured values change; they never block Polish or change readiness. The comparison uses the HTML export as the design; a design file recorded for the page is not read by this version.
125
127
 
126
128
  ### Package captures and design comparison
@@ -65,10 +65,13 @@ rename; a live process is never evicted. A lock directory with no owner record
65
65
  a second. If one is found, or if recovery itself is interrupted, capture fails
66
66
  closed and the warning names the affected `.allocation-lock` directory: stop
67
67
  all Campaigns OS writers for that target, then remove that directory before
68
- retrying `next`. A file or other non-lock entry at the lock path is named in
69
- the warning and never removed: move it out of the way. Do not remove a lock
70
- while a writer is active. Do not run an
71
- older Campaigns OS release against the same target at the same time.
68
+ retrying `next`. A file, an unreadable directory or other non-lock entry at
69
+ the lock path is named in the warning and never removed: move it out of the
70
+ way. Do not remove a lock while a writer is active. Run one Campaigns OS
71
+ release against a target at a time: a lock held by a live process of another
72
+ release (its owner record has no `lock_format`, or a different one) is refused
73
+ at once and the warning names that process. Two releases that start at the
74
+ same instant are not detected.
72
75
 
73
76
  An unchanged projection reuses its ID, timestamp and sequence. Identity
74
77
  changes start a new stream. Each local scope retains at most 32 snapshots and
@@ -134,6 +134,21 @@ waiver commands keep them strict. Progress
134
134
  snapshots record a carried-forward gate as `not_applicable`, because their
135
135
  schema has no carried-forward state.
136
136
 
137
+ ### Polish advisory for a page-only change
138
+
139
+ After launch, a change that touches only some pages' HTML and leaves checkout,
140
+ commerce markup, routes, scripts and the campaign's Page Kit config alone is a
141
+ page-only change ([what a change owes](campaigns-os-build-flow.md#what-a-change-owes)).
142
+ On any target, its stale Polish evidence (`polish.evidence_missing` after
143
+ `record build`, `polish.stale`, `polish.output_drift`, and the
144
+ `polish.hidden_eager_media.capture_stale` checkpoint) is carried forward with
145
+ the `page_only_change` policy. Doctor reports it as a warning starting
146
+ "Advisory for a page-only change"; `next` and `record deploy` move past Polish;
147
+ QA records it as a `skipped` row, so it does not hold the verdict to
148
+ `ready_with_exceptions`. A Polish recorded as blocked, incomplete or
149
+ self-certified stays blocking, and so does a stage a brief or spec change
150
+ requires.
151
+
137
152
  ## Resolve
138
153
 
139
154
  Use resolve before a full run:
@@ -381,6 +396,40 @@ default currency. The persisted verdict redacts every URL query, so the
381
396
  currency is recorded as `evidence.currency` on the entry page's
382
397
  `http:<page_id>` assertion; an entry URL that does not parse gets no currency,
383
398
  and its row records `evidence.currency_not_applied` with the reason instead.
399
+
400
+ The run also checks that the currency took. Campaign Cart falls back to the
401
+ campaign's default currency when the requested one is not available, so a page
402
+ can load with `?currency=GBP` and still take a USD order:
403
+
404
+ - Each test order gets a `browser-order-currency:<path>` row comparing the
405
+ created order's `currency` (read from the order API response) with the run
406
+ currency. An order in any other currency fails as a blocker, and so does a
407
+ verified order whose currency was not recorded. The row's evidence is
408
+ `{ requested, order_currency }`.
409
+ - Automatic commercial parity (below) asks the calculate endpoint for expected
410
+ totals in the run currency, in place of the Map's `campaign.currency`. When
411
+ the endpoint answers in another currency, the campaign does not price in the
412
+ run currency: a `commercial-currency:<RUN>:<ANSWERED>` row (family `pricing`)
413
+ fails as a blocker, and the commercial report lists the
414
+ `calculate_currency_mismatch` issue, so its status reads `incomplete`. The
415
+ row's id names the answered code upper-cased, one row per answered currency,
416
+ and it is written even when the verdict's assertion budget is used up.
417
+ - Commercial parity reads prices from each page's source HTML, where a bound
418
+ price such as `data-next-display="bundle.main.price"` holds the authored
419
+ fallback text in the Map's `campaign.currency` (Campaign Cart replaces it at
420
+ runtime). When the run currency is not the Map's `campaign.currency`, those
421
+ price and recurring-price claims are not compared with the run-currency
422
+ totals: they count as unresolved, and the commercial report records
423
+ `unresolved_money_claims_reason: "static_source_not_in_run_currency"`. A
424
+ correctly built multi-currency page gets no `price-claim-mismatch` warning,
425
+ and the commercial status reads `incomplete` instead of `pass`. With
426
+ `--currency` set to the Map's own currency, the claims are compared as
427
+ before.
428
+
429
+ Either blocker makes the verdict `blocked` and the command exit `4`. Without
430
+ `--currency` neither row is written and expected totals use
431
+ `campaign.currency`, as before.
432
+
384
433
  `--currency` sets the currency only. Test orders still ship to the address the
385
434
  `--test-*` flags give, a US address by default (see
386
435
  [Test customer and shipping address](#test-customer-and-shipping-address)). A
@@ -390,6 +439,25 @@ checked: a currency such as EUR covers many countries, so an explicit
390
439
  `--test-country` is taken as meant. `qa run --site` takes `--currency` the same
391
440
  way and tags the slug's entry page.
392
441
 
442
+ `--page <page_id>` (repeatable) checks only the named pages. It pays the QA a
443
+ page-only change owes: `doctor --ci` and `next` name the changed pages by
444
+ their route ids (`upsell1` for `upsell1/index.html`), and the CampaignSpec page
445
+ id is accepted too. It needs `--packet`, places no test orders (it refuses
446
+ `--test-order` and `--legacy-api-test-order` other than `off`, and
447
+ `--vendor-beacons`), and refuses before checking anything when the
448
+ output on disk is not the recorded build (run `record build` first), when the
449
+ change since the build QA reviewed touches commerce, when a changed page is
450
+ left out, when the QA it would extend did not complete (a blocked full run
451
+ owes a full run), or when QA's inputs (the brief's `qa_policy` and the
452
+ CampaignSpec) changed or cannot be confirmed current. Every other page gets a
453
+ `page-scope:<page_id>` row, `skipped` with `outside_page_scope`. The QA stage
454
+ it records carries `evidence.page_scope` (`pages`, `owed_pages`,
455
+ `base_build_fingerprint`) and keeps the `purchase_proof` of the full run it
456
+ extends. Its `evidence.qc_results` holds the rows for the scoped pages only,
457
+ so until the next full run the QC handoff coverage reports the earlier full
458
+ run's other QA checks (order paths and tracking among them) as not captured.
459
+ QA already recorded in full for the current build is re-run without `--page`.
460
+
393
461
  ### Automatic commercial parity
394
462
 
395
463
  When the CampaignSpec has enabled pages with package rows, the same `qa run`
@@ -440,7 +508,11 @@ readback consumes. It is written for every finalized disposition, blocked
440
508
  included: the sidecar records what QA concluded, it is not a pass mark. A run
441
509
  that dies before verdict finalization or fails local validation never touches
442
510
  an existing sidecar. Packet-less runs (`--site`, raw map-id) have no packet
443
- home and write no sidecar.
511
+ home and write no sidecar. For a project's second and later funnels the sidecar
512
+ is `.campaign-runtime/funnels/<route>/qa-verdict.json` beside that funnel's
513
+ packet, the runtime directory its `assembly.runtime_dir` names (see
514
+ [Several funnels in one project](build-packet.md#several-funnels-in-one-project));
515
+ `qa promote` and `qa publish` read and write the same file.
444
516
 
445
517
  The sidecar is an allowlist **projection** of the full verdict, same schema
446
518
  (`1.0`), stamped with its own `generated_at` at promotion time. Full verdicts
@@ -1337,6 +1409,80 @@ controls on an ordinary or unknown page remain blockers; they are not treated
1337
1409
  as evidence that a receipt was reached. Cross-origin handoffs count as terminal
1338
1410
  navigation, but not as Campaigns OS receipt rendering or persisted-receipt proof.
1339
1411
 
1412
+ ### Analytics vendor beacons (`--vendor-beacons live`)
1413
+
1414
+ A test order is a real purchase on the page, so the page reports it to every
1415
+ analytics vendor the campaign loads. To keep test orders out of vendor data, a
1416
+ `qa run --test-order` run answers the vendors' beacon requests locally. A
1417
+ beacon is the request a vendor script sends to report a page view or an event,
1418
+ as opposed to the loader, the request that downloads the vendor script. Every
1419
+ browser context the run opens (page checks, analytics capture and the orders)
1420
+ answers a request to a listed beacon endpoint with an empty `204` response, so
1421
+ nothing reaches the vendor. Those contexts also block service workers, because
1422
+ a request a page's service worker makes on the page's behalf would bypass the
1423
+ local answer. Loader scripts load normally: a blocked loader would leave the
1424
+ vendor script undefined, and the page would send nothing to observe.
1425
+
1426
+ | Vendor | Beacon endpoints answered locally | Loaders that still load |
1427
+ |---|---|---|
1428
+ | GA4 (`ga4`) | `*.google-analytics.com/g/…`, `analytics.google.com/g/…`, `stats.g.doubleclick.net/g/…`, `www.google.com/g/collect` | `www.googletagmanager.com/gtag/js` |
1429
+ | Meta (`meta`) | `www.facebook.com/tr`, `www.facebook.com/privacy_sandbox/…` | `connect.facebook.net` |
1430
+ | TikTok (`tiktok`) | `analytics.tiktok.com/api/v2/…`, `analytics-ipv6.tiktokw.us` | `analytics.tiktok.com/i18n/pixel/…` |
1431
+ | Snap (`snap`) | `tr.snapchat.com`, `tr6.snapchat.com`, `tr-shadow.snapchat.com` | `sc-static.net` |
1432
+ | Pinterest (`pinterest`) | `ct.pinterest.com/v3/…`, `/user/…`, `/stats/…` | `s.pinimg.com`, `ct.pinterest.com/static/…` and `/ct.html` |
1433
+ | Taboola (`taboola`) | `trc.taboola.com` | `cdn.taboola.com` |
1434
+ | Triple Whale (`triplewhale`) | `api.config-security.com` (all but `/first`), `api.whaledb.io/keep-alive` | `api.config-security.com/first`, `conf.config-security.com` |
1435
+ | AppLovin Axon (`axon`) | `b.applovin.com/v1/pixel` | `s.axon.ai`, `c.albss.com`, `res4.applovin.com` |
1436
+
1437
+ Each endpoint was read from the vendor's public loader script or public
1438
+ documentation; the source is recorded per vendor in
1439
+ `src/qa-vendor-endpoints.mjs`. Two vendor keys are not intercepted, and the
1440
+ verdict names them:
1441
+
1442
+ - `northbeam`: its beacon host could not be confirmed (the loader is per client
1443
+ and the public docs name no beacon host), so Northbeam still receives what
1444
+ the page sends it, test purchases included.
1445
+ - `gtm`: Google Tag Manager sends nothing of its own. The tags it loads send to
1446
+ their own vendors, held back only when that vendor is in the table. A GTM tag
1447
+ for any other vendor (Google Ads, an affiliate pixel) still sends.
1448
+
1449
+ `qa parity` orders are not intercepted.
1450
+
1451
+ Every request to a listed endpoint is recorded, and the verdict carries one
1452
+ informational row, `browser-test-order:vendor-beacons` (status `pass`, severity
1453
+ `info`). Its `evidence` gives, per vendor seen, the request count, whether one
1454
+ of the requests named a purchase event (`purchase_seen`; `null` for Axon, whose
1455
+ event field could not be confirmed), and the ids, event names and endpoints
1456
+ (origin and path only, never a query string); `configured_not_seen` lists the
1457
+ vendors the CampaignSpec analytics block declares that sent nothing to a listed
1458
+ endpoint; `configured_not_observable` lists the declared vendors with no listed
1459
+ endpoint (GTM, Northbeam), which the row cannot see either way; and
1460
+ `not_intercepted` lists the two vendors above with the reason for each (with
1461
+ `live`, every vendor). The row is evidence only. A
1462
+ request the page sent proves the page sent it, not that the vendor received it,
1463
+ so it never counts as vendor purchase proof and never blocks the run. Purchase
1464
+ proof stays with `analytics-correctness:purchase-fires` (the dataLayer
1465
+ purchase, a Meta Purchase pixel request, or a GA4 purchase hit); with
1466
+ interception on, the Meta and GA4 requests behind it are still observed, just
1467
+ answered locally. The same requests feed the analytics inventory and parity
1468
+ legs as tag fires.
1469
+
1470
+ To send the beacons to the vendors, pass `--vendor-beacons live`. `live` is
1471
+ the only value; any other value is refused before anything runs, and so is the
1472
+ flag on a run without `--test-order`, where it would change nothing. The row
1473
+ then records the requests as sent.
1474
+
1475
+ ```bash
1476
+ npm run campaigns-os -- qa run \
1477
+ --packet campaign-runtime.build.json \
1478
+ --test-order common \
1479
+ --vendor-beacons live
1480
+ ```
1481
+
1482
+ Do not hold beacons back with `?ignore=true` on the campaign URL: from SDK
1483
+ 0.4.42 it stops the SDK's `dl_*` events entirely, so the run would prove no
1484
+ purchase event at all.
1485
+
1340
1486
  ### Purchase-proof coverage (`--test-order off` is a diagnostic)
1341
1487
 
1342
1488
  A QA run finalizes a verdict and records a terminal stage status whether or not
package/docs/readback.md CHANGED
@@ -295,6 +295,20 @@ carries no parseable value — the readback does not choose. It names the
295
295
  candidates on stderr, says `--packet` is required, and exits `2`. No JSON object
296
296
  is emitted in that case.
297
297
 
298
+ **Freshness chooses among runs of one funnel, never among funnels.** A project
299
+ that holds several funnels has one packet per funnel
300
+ (`campaign-runtime-<route>.build.json` beside the default; see
301
+ [Several funnels in one project](build-packet.md#several-funnels-in-one-project)).
302
+ When the valid candidates name more than one `campaign.public_route_slug`, the
303
+ readback refuses the same way, naming each packet with its route, and exits
304
+ `2`. Pass `--packet` to project one funnel. The doctor output, Build Context,
305
+ Assembly Report, QA verdict and findings export rows then read from the runtime
306
+ directory that packet records (`assembly.runtime_dir`), or from
307
+ `.campaign-runtime/` when it records none. A packet whose `assembly.runtime_dir`
308
+ is not its own funnel's directory is refused with exit `2`, since the readback
309
+ cannot tell whose records to show. An explicit `--doctor`, `--context`,
310
+ `--report`, `--qa-verdict` or `--findings` path still wins.
311
+
298
312
  Values are compared at **microsecond precision** — the precision Python's
299
313
  `datetime.fromisoformat` keeps, and the precision the Python readback this
300
314
  command ports compared at; a seventh or later fractional digit is truncated, not
@@ -495,11 +509,31 @@ If the built page map cannot be resolved (for example, output contains assets
495
509
  but no HTML), page changes are unknown with `build_output_pages_unresolved`;
496
510
  stage bindings still follow the fingerprint comparison.
497
511
 
512
+ `changes.kind` classifies this drift (`none`, `page_only`,
513
+ `commerce_affecting` or `unknown`) and `changes.reasons` lists why it counts as
514
+ commerce, each `{ "code", "path"? }`. The rule is in
515
+ [what a change owes](campaigns-os-build-flow.md#what-a-change-owes).
516
+
498
517
  The assembly baseline is explicit because it need not equal an older stage's
499
- binding. If a later `record build` replaced that manifest, readback can still
500
- report older Polish/QA bindings as owed, but cannot reconstruct which pages
501
- changed from those older builds. An empty **known** list then means only that
502
- no files differ from the retained assembly manifest. If recording removed a
518
+ binding. If a later `record build` replaced that manifest, `changes` can be
519
+ empty while older Polish/QA bindings are still owed. An empty **known** list then means only that
520
+ no files differ from the retained assembly manifest. The pages changed since
521
+ the build a stage reviewed are on that stage instead. An owed stage carries:
522
+
523
+ - `change`: `{ "kind", "since_build_fingerprint", "changed_pages",
524
+ "unmapped_files", "reasons" }`, read from the rebuilds `record build` kept in
525
+ `stages.assembly.build_changes` plus the drift on disk. `kind` is `unknown`
526
+ when that history does not reach the reviewed build (reason
527
+ `change_history_unavailable`) or the drift cannot be read. `null` on a stage
528
+ that is not owed.
529
+ - Polish only, `advisory`: `true` when the change is `page_only` and no brief or
530
+ spec change requires Polish. Advisory Polish is reported; `next`, `doctor
531
+ --ci` and the QA gate do not require it.
532
+ - QA only, `scope` and `owed_pages`: `pages` with the changed pages when the
533
+ change is `page_only` (`qa run --page` pays it), `full` otherwise, `null`
534
+ when QA is not owed.
535
+
536
+ If recording removed a
503
537
  stage binding entirely, that stage reads unknown unless its recorded status is
504
538
  `required`, which reports owed. `current` is a build-binding
505
539
  assessment only; input currency, stage completion, waivers and verdicts remain
@@ -548,7 +582,8 @@ failure lines up with a completed stage. This too is the readback's own layer.
548
582
  4. **`divergences` is empty.**
549
583
  5. **`doctor.error_count` is zero.**
550
584
  6. **Neither `evidence_debt.polish.status` nor `evidence_debt.qa.status` is
551
- `owed`.** Built output that moved since recorded evidence cannot read clean.
585
+ `owed`**, except Polish marked `advisory` for a page-only change. Built
586
+ output that moved since recorded evidence cannot read clean.
552
587
  An `unknown` status does not independently make this integrity flag false;
553
588
  it remains unknown, never a claim that no evidence is owed. Callers must
554
589
  inspect `evidence_debt` even when `clean` is true.