@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,1691 @@
1
+ # QA And Test Orders
2
+
3
+ The public v0 QA runner is Node/npm-based and does not require access to a private runtime repo.
4
+
5
+ > **Commerce QA requires network; it cannot run in a no-outbound sandbox.** The SDK, product images, fonts, the Netlify preview, and the Playwright typed-card test order all need outbound network. A build environment without it can only validate markup/build/CSS — the commerce runtime and the typed-card test order (the Campaigns OS control) must be deferred to a deployed preview. Always run the QA runner against a `--base-url` preview/production origin (e.g. `npm run campaigns-os -- qa run --packet campaign-runtime.build.json --base-url https://deploy-preview-7--your-site.netlify.app/ --browser --test-order common`); never report commerce-runtime QA as passed from an offline build.
6
+
7
+ ## Polish capture prerequisite
8
+
9
+ Packet QA consumes package-owned page-load evidence; it never creates that
10
+ evidence. Install the package browser, serve the current built output, and run
11
+ the producer before marking Polish complete, deploying, or starting QA:
12
+
13
+ ```bash
14
+ npm run qa:install-browser
15
+ npm run campaigns-os -- polish capture \
16
+ --packet campaign-runtime.build.json \
17
+ --base-url <served-current-build-url>
18
+ ```
19
+
20
+ The operator-provided URL must serve the current build. Its value is not a
21
+ cryptographic attestation of the served bytes. See
22
+ [Polish evidence](./polish-evidence.md#durable-page_load-field-map) for the
23
+ generated field map, completeness rules, and attachment race boundary.
24
+
25
+ ## Local proof mode (`deploy.target: local-serve`)
26
+
27
+ Campaign development proves on localhost first and commits second; the PR
28
+ preview is the second check, not the first. Under `deploy.target: local-serve`
29
+ the toolkit runs that loop in a fixed order:
30
+
31
+ 1. **Build in development.** The build stage runs page-kit in the development
32
+ environment — `CPK_ENV=development npx campaign-build --json >
33
+ .campaign-runtime/page-kit-build-summary.json` — into the target's normal
34
+ `_site/`, and records `stages.assembly.evidence.build_environment:
35
+ "development"` on the Assembly Report. `next build` names the command as
36
+ the `build_local_proof` action and in the build prompt; doctor warns
37
+ (`local_proof.build_environment`) on a completed build that is not recorded
38
+ as a development render. The starter templates gate every vendor loader on
39
+ `{% unless environment == "development" %}`, several of those loaders are
40
+ protocol-relative (`//host/...`), and over a plain-HTTP local serve they
41
+ resolve to `http://host/...` and fail — which voids polish capture
42
+ unwaivably. The SDK's `dl_*` events still fire in development, so browser
43
+ QA and typed-card orders prove the same runtime. The development render
44
+ goes into `_site/` rather than a sibling directory because polish capture,
45
+ `qa run`, and every `built_output.*` doctor check root at
46
+ `_site/<public_route_slug>/`; the production build never needs to coexist
47
+ with it locally (the parity step renders it to a temp dir, and the deploy
48
+ host renders it from the committed source).
49
+ 2. **Serve and prove.** Serve `_site/` on localhost (the `next deploy` handoff
50
+ names the directory and any root-route rewrite), record the URL on
51
+ `deploy.preview_url`, then run `polish capture`, `qa run --browser`, and the
52
+ typed-card order paths against it.
53
+ 3. **Prove the pin on the production output.** Before committing, run
54
+
55
+ ```bash
56
+ npm run campaigns-os -- page-kit parity --packet campaign-runtime.build.json
57
+ ```
58
+
59
+ It renders the current source in development and in production through
60
+ the target's own page-kit into temp directories (nothing is written under
61
+ the target except the result on the Assembly Report) and asserts, per page,
62
+ that the served `_site/` is byte-identical to the current development
63
+ render, that the page set and route slugs agree across all three, and that
64
+ the Campaign Cart loader pin and the `next-api-key` meta are the same in
65
+ the proven output and the production render (and match
66
+ `_data/campaigns.json[<slug>].sdk_version` when it is readable). What
67
+ "environment-gated" means is derived from the templates' rendered output —
68
+ the difference between the development and production renders of the same
69
+ source — never from a vendor list; the pass summary lists the gated line
70
+ counts and loader hosts per page. The result lands on
71
+ `stages.assembly.evidence.local_proof.production_parity`; doctor reports it
72
+ as `local_proof.production_parity` (ready line on pass; an error naming the
73
+ first non-gated difference — `sdk_pin_mismatch`, `sdk_pin_drift`,
74
+ `sdk_loader_missing`, `proven_output_is_production`,
75
+ `proven_output_stale`, a page-set kind — on fail; a warning while
76
+ unrecorded or recorded for another build fingerprint). Exit 2 on fail, and
77
+ on a pass that could not be recorded (`status: record_failed`), so the
78
+ command never claims what doctor cannot read.
79
+ 4. **Commit, then open the PR.** The preview deploy is the second check.
80
+
81
+ The toolkit never proposes editing a generated include (`analytics-head.html`,
82
+ `analytics-body.html`, or any `_includes/` file marked GENERATED) to make a
83
+ local capture pass. A polish capture over plain HTTP whose ledger shows a
84
+ failed cross-origin `http:` dependency is that signature exactly, and the
85
+ checkpoint's first required action becomes
86
+ `polish.hidden_eager_media.local_proof_rebuild`: rebuild in development and
87
+ recapture. A hosted target (`netlify`, `cloudflare-pages`, …) is unaffected:
88
+ its build stage renders production as before and `page-kit parity` refuses the
89
+ packet (`local_proof.parity.not_local_serve`).
90
+
91
+ ## Resolve
92
+
93
+ Use resolve before a full run:
94
+
95
+ ```bash
96
+ npm run campaigns-os -- qa resolve --packet campaign-runtime.build.json
97
+ ```
98
+
99
+ Resolve reads the packet, loads the local CampaignSpec when available, derives deployed page URLs from the packet deploy URL or `--base-url`, probes the entry URLs it derived, and prints the funnel topology. It does not create a verdict.
100
+
101
+ ### Route reachability
102
+
103
+ A route set derived from the packet is not evidence that the deployment serves
104
+ it. Resolve therefore probes the entry URLs it just printed — one `HEAD` per
105
+ entry URL, retried as `GET` only when a host answers `405`/`501` about the
106
+ method — and its status reports what was verified rather than what was derived:
107
+
108
+ | Status | Meaning |
109
+ | --- | --- |
110
+ | `blocked` | A checkpoint gate blocks. The routes are not probed. |
111
+ | `routes_unresolved` | The routes were probed and at least one did not resolve. `ok: false`. |
112
+ | `ready_unprobed` | There were routes to probe and none produced a response. `ok: true`. |
113
+ | `ready_with_exceptions` | Checkpoint warnings, over routes that resolved. |
114
+ | `ready` | Clean, over routes that resolved. |
115
+
116
+ The ladder is ordered by how much of the deployment the run actually verified,
117
+ which is why `ready_unprobed` outranks `ready_with_exceptions`: a checkpoint
118
+ warning is a named exception an operator can read, while an unprobed route set
119
+ means the deployment half was never checked. Checkpoint warnings stay fully
120
+ visible in `checkpoint_gates[]` at every rung.
121
+
122
+ `routes_unresolved` names the first URL that failed and suppresses the
123
+ `qa run --browser --test-order common` suggestion, because that command cannot
124
+ succeed against a route set that does not resolve. The `route_probe` block
125
+ carries the per-URL results and one of `route_probe.all_resolved`,
126
+ `route_probe.routes_unresolved`, `route_probe.unreachable`,
127
+ `route_probe.disabled`, or `route_probe.no_routes`.
128
+
129
+ `route_probe.first_failure` is the first result that did not cleanly resolve,
130
+ **in the order the entry URLs were derived** — the order they are printed under
131
+ `Entry URLs:` — not the order the responses happened to land. It is populated on
132
+ every status where something failed, `route_probe.all_resolved` included: a pass
133
+ reached over some unreachable URLs is partial reachability, and both the reason
134
+ line and the printed per-URL rows say which URLs those were rather than leaving
135
+ an operator to infer it from `counts.unreachable`. It is `null` only when every
136
+ probed URL resolved, or when nothing was probed at all.
137
+
138
+ Resolve appends `campaign.public_route_slug` unconditionally — the packet is
139
+ the authority on where a campaign is served, and no flag overrides it. When
140
+ every derived route is dead, one extra probe of the host without that slug
141
+ separates the two causes and says which it found:
142
+ `route_probe.route_root_mismatch` (the host serves the campaign under a
143
+ different route root, so correct `campaign.public_route_slug` or declare
144
+ `campaign.route_root` in the packet) or `route_probe.host_also_dead` (the
145
+ preview itself is down).
146
+
147
+ **Offline and CI.** An HTTP response saying `404` is evidence about the
148
+ deployment; a transport error is evidence about this machine's network. Only
149
+ the first fails the probe. A run with no outbound network degrades to
150
+ `ready_unprobed` and stays usable — no flag required. `--no-probe` exists for
151
+ hermetic runs that must make no outbound request at all, and
152
+ `--probe-timeout-ms` (default `5000`) bounds each probe. Probing is capped at
153
+ 25 entry URLs; anything past the cap is reported as `skipped` rather than
154
+ silently dropped.
155
+
156
+ An empty Entry URL list keeps its own pre-existing guidance — a dead preview or
157
+ a missing `--base-url` — and reports `route_probe.no_routes` without moving the
158
+ status.
159
+
160
+ ### Packet-local checkpoint preflight
161
+
162
+ Packet QA reads one local packet, CampaignSpec, target `_data/campaigns.json`
163
+ entry, and Assembly Report snapshot. It evaluates four registered checkpoints
164
+ from those objects: `page_kit.store_profile`, `page_kit.sdk_version`,
165
+ `polish.hidden_eager_media`, and `built_output.upsell_selector_scope`. The same packet/spec snapshot supplies runtime
166
+ identity and topology, while the same Assembly Report supplies checkpoint
167
+ decisions, theme/polish state, package-owned page-load evidence, and QA waiver
168
+ history. QA does not re-read those artifacts after the gates. A packet without
169
+ a valid local spec cannot fetch around the missing evidence. Packet QA always
170
+ uses `packet.spec.local_path`; combining `--packet` with `--spec` is rejected
171
+ before either artifact is read.
172
+
173
+ Any non-waived checkpoint blocker finalizes a blocked local verdict before HTTP,
174
+ Playwright, analytics capture, or typed-card orders run. All checkpoint
175
+ assertions remain visible when gates disagree, so waiving or correcting one
176
+ never suppresses another. Store Profile and SDK use the `api-metadata` family;
177
+ the hidden eager-media assertion uses `polish_gate`.
178
+
179
+ A gate's `status` is the blocking axis only, not a cleanliness signal. A gate
180
+ can report `status: pass` and still carry non-blocking findings: when the target
181
+ `_data/campaigns.json` entry declares a governed Store Profile field the
182
+ CampaignSpec leaves empty, `page_kit.store_profile` passes with
183
+ `code: page_kit.store_profile.target_only` and names those fields in
184
+ `warning_fields[]`. `qa resolve` reads `warning_fields[]` (and an active
185
+ waiver), not `status`, when it chooses between `ready` and
186
+ `ready_with_exceptions`, and packet QA turns the same array into a WARN
187
+ assertion. So read a gate's `code` and `warning_fields[]` rather than treating
188
+ `pass` as clean.
189
+
190
+ A blocked gate downgrades a requested browser pass visibly. When `--browser` was
191
+ passed and a blocked checkpoint, polish, or theme gate finalized the run before
192
+ any page was rendered, the verdict carries
193
+ `browser: { requested: true, status: "skipped_gate_blocked", blocked_by: [<gate
194
+ codes>], reason }` and the run prints that reason once on stderr, naming the
195
+ gate and quoting that gate's own `required_actions` for what clears it (so a
196
+ stale-assembly polish blocker asks for a fresh Build, and a waive command
197
+ appears only where the gate is waivable). The gate decision and the exit code are unchanged (`4`, blocked);
198
+ only the silence is. The field is emitted for that case alone, so its absence
199
+ means the verdict makes no claim about a browser pass — read the
200
+ `browser-runtime` assertions and `tested_urls` to tell whether one ran. It is
201
+ not part of the committed sidecar's allowlist projection, and `--json` runs get
202
+ the stamp in the emitted verdict instead of the stderr line.
203
+
204
+ `qa resolve` remains a diagnostic command and always exits 0: it reports
205
+ `ok: false` and `status: blocked`, prints all four gates and their safe
206
+ repair/waiver projections, and suppresses the runtime
207
+ `qa run --browser --test-order common` suggestion until every checkpoint
208
+ blocker is clear. `routes_unresolved` behaves the same way — `ok: false`,
209
+ suggestion suppressed, exit 0.
210
+
211
+ The SDK gate reads the canonical `global_config.sdk_version` first and accepts
212
+ `runtime.sdk_version` as an alias. Pins must be released,
213
+ canonical `MAJOR.MINOR.PATCH` versions. Equal dual declarations are valid;
214
+ conflicting declarations, missing declarations, prereleases, empty values, and
215
+ non-string values are non-waivable blockers. Once both sides are valid, only an
216
+ exact expected/observed mismatch has a waiver lane.
217
+
218
+ The hidden eager-media gate reads only the recorded package capture; QA never
219
+ launches `campaigns-os polish capture` or another browser producer. Missing,
220
+ malformed, stale, integrity-invalid, route-mismatched, or incomplete page-load
221
+ evidence is nonwaivable and blocks before runtime. A complete finding for a
222
+ computed-hidden media element strictly over `1,048,576` bytes is waivable only
223
+ for its exact build, slug, route plan, fixed viewports, and finding state. A
224
+ packetless QA run has no packet-owned authority and reports this checkpoint as
225
+ not applicable.
226
+
227
+ A current exact checkpoint waiver remains attached to that gate's warning and
228
+ lets runtime QA proceed only when every other checkpoint is clear. The QA
229
+ disposition is `ready_with_exceptions`; waived is never clean. Doctor/`next` use
230
+ the checkpoint readiness term `ready_with_waivers`. Record a bounded decision
231
+ before QA with the relevant gate ID:
232
+
233
+ ```bash
234
+ campaigns-os checkpoint waive \
235
+ --packet campaign-runtime.build.json \
236
+ --gate <page_kit.store_profile|page_kit.sdk_version|polish.hidden_eager_media|built_output.upsell_selector_scope> \
237
+ --reason "<why>" \
238
+ --waived-by "<named human>" \
239
+ --review-condition "<specific re-evaluation trigger>"
240
+ ```
241
+
242
+ Legacy source/theme/QA waiver commands and artifact lanes remain in place until
243
+ those gates are registered. Store Profile evidence includes only the governed
244
+ nine-field matrix plus status, normalized slug, relative target path,
245
+ fingerprint, and attribution. SDK evidence includes only strict expected and
246
+ observed versions, declaration source, status, subject, fingerprint, and the
247
+ same bounded attribution. Arbitrary target campaign configuration must not be
248
+ serialized into the verdict. Active waiver evidence is a fixed whitelist of
249
+ attribution/bound fields, and inactive waiver history is count-only; raw report
250
+ records and their unknown fields never enter resolve output or the verdict.
251
+
252
+ Use the printed `Entry URLs` for preview probes and proof notes. The campaign
253
+ root is only the URL-joining base; some funnels enter through a more specific
254
+ route such as `/shield/presell-running/`, and the root path may legitimately
255
+ 404. Treat a root 404 as legitimate only when `qa resolve` prints at least one
256
+ Entry URL and the follow-up `qa run` records a passing `http:<page_id>` assertion
257
+ for that entry URL. If Entry URLs are empty, still point at a deleted preview, or
258
+ fail their own HTTP assertion, fix `--base-url` or the packet deploy URL before
259
+ continuing.
260
+
261
+ `--base-url` can be either the deploy host or the campaign root. If the Build Packet says `campaign.public_route_slug = "roadside-ready"`, both of these resolve pages under `/roadside-ready/`:
262
+
263
+ ```bash
264
+ npm run campaigns-os -- qa resolve --packet campaign-runtime.build.json --base-url https://deploy-preview.example.netlify.app
265
+ npm run campaigns-os -- qa resolve --packet campaign-runtime.build.json --base-url https://deploy-preview.example.netlify.app/roadside-ready/
266
+ ```
267
+
268
+ ## Run
269
+
270
+ Install the package-owned Playwright browser once before Polish capture,
271
+ rendered QA, or test-order proof:
272
+
273
+ ```bash
274
+ npm run qa:install-browser
275
+ ```
276
+
277
+ This installs the Chromium binary used by `polish capture`, `--browser`, and
278
+ `--test-order`. It is part of the normal Campaigns OS proof path after `npm
279
+ install` or package updates. The QA flow must not depend on external browser
280
+ skills or local agent tooling.
281
+
282
+ `npm run smoke:polish-capture` is an optional real-browser package smoke after
283
+ that installation. It requires permission to bind a loopback HTTP listener and
284
+ is deliberately excluded from `npm run check` and CI.
285
+
286
+ ```bash
287
+ npm run campaigns-os -- qa run \
288
+ --packet campaign-runtime.build.json \
289
+ --base-url https://preview.example.com/campaign/
290
+ ```
291
+
292
+ The runner fetches deployed pages, checks route availability, verifies CampaignSpec `sdk_hints.meta_tags` (a key the Campaign Cart SDK does not read, `next-currency` or `next-predictive-address` from `src/sdk-meta-tags.mjs`, is a `warn` row at severity `warn` carrying the shared note, never `manual_review` and never a blocker, whether or not the tag rendered; doctor reports the same key as `sdk_hints.meta_tags.ignored_by_sdk`), writes a local verdict JSON under `<target-repo>/qa-output/<map-id>/<run-id>.json` (the packet's `assembly.target_repo`, else the packet's directory; `--output-dir` overrides it, and a packet-less run uses `qa-output/` under the current directory), and returns exit code `4` when the verdict is blocked. The target's managed ignore block lists `qa-output/`, because full verdicts carry live storefront URLs; the committed form is the `.campaign-runtime/qa-verdict.json` projection.
293
+
294
+ ### Automatic commercial parity
295
+
296
+ When the CampaignSpec has enabled pages with package rows, the same `qa run`
297
+ automatically plans calculate scenarios from the packet's raw CampaignSpec and
298
+ executes them through the existing proxy `POST /api/price-preview` contract.
299
+ The runner uses the established credential precedence: a direct packet
300
+ `campaign.campaigns_api_key`/`api_key`, then CampaignSpec key fields, then the
301
+ single trusted packet fallback `campaign.api_key_source = "env:CAMPAIGNS_API_KEY"`.
302
+ Other environment-variable names are rejected so an untrusted packet cannot
303
+ forward unrelated process credentials to an overridden proxy. The resolved value is supplied
304
+ only as the `X-Campaign-Key` request header and is never serialized into the verdict.
305
+ No private runtime import, commercial sidecar, duplicated price calculation, or
306
+ extra catalog flag is involved. Recurring package facts come from the authored
307
+ page rows carried into each portable descriptor.
308
+
309
+ The deployed source response is fetched once per distinct URL and shared by
310
+ the normal static checks and commercial extractor. Limits are deliberately
311
+ hard: 2 MiB per HTML response, 16 MiB of retained HTML across the run, 50,000 parsed elements, nesting depth 128, 500
312
+ claims per document, 256 claims across the run, 1 MiB per price-preview
313
+ response, 256 calculate scenarios, four concurrent proxy requests, and 20
314
+ seconds per request. A limit, missing key, malformed response,
315
+ or unavailable page records incomplete commercial evidence; it never invents
316
+ a mismatch. `commercial.status = "incomplete"` preserves the disposition
317
+ derived from the ordinary QA assertions; it does not create an exception by
318
+ itself. QA dispositions remain `ready`, `ready_with_exceptions`, or `blocked` —
319
+ `ready_with_waivers` is the doctor/`next` checkpoint-readiness term.
320
+
321
+ Only contract-governed claims are compared, and only against `Exact` normalized
322
+ truth. Proven differences emit warn-severity `pricing` assertions named
323
+ `price-claim-mismatch`, `cadence-disclosure-mismatch`, or
324
+ `voucher-not-applied`. Decorative, ambiguous, stale, unresolved, or malformed
325
+ claims remain silent. The verdict's top-level `commercial` section records
326
+ coverage, sanitized missing/unmatched/invalid capture evidence, proxy issues,
327
+ and findings; the same findings are serialized deterministically into the flat
328
+ `assertions` array consumed by existing QA tooling. A proven mismatch keeps the
329
+ verdict at `ready_with_exceptions` even when the flat assertion budget retains
330
+ the finding only under `verdict.commercial`.
331
+
332
+ ### Committed verdict sidecar (`.campaign-runtime/qa-verdict.json`)
333
+
334
+ Packet-based `qa run` also writes a committed sidecar beside the Build Packet
335
+ at `.campaign-runtime/qa-verdict.json` — the artifact campaigns-agent's
336
+ readback consumes. It is written for every finalized disposition, blocked
337
+ included: the sidecar records what QA concluded, it is not a pass mark. A run
338
+ that dies before verdict finalization or fails local validation never touches
339
+ an existing sidecar. Packet-less runs (`--site`, raw map-id) have no packet
340
+ home and write no sidecar.
341
+
342
+ The sidecar is an allowlist **projection** of the full verdict, same schema
343
+ (`1.0`), stamped with its own `generated_at` at promotion time. Full verdicts
344
+ under `qa-output/` are gitignored because they carry live storefront URLs,
345
+ request evidence, and order references; the projection keeps identity,
346
+ disposition, per-assertion `id`/`family`/`page`/`status`/`severity`/
347
+ `blocked_by`, and trimmed exceptions, and empties every URL-bearing field. Do
348
+ not commit a full verdict, and do not hand-author the sidecar.
349
+
350
+ To backfill from an existing full verdict, name the exact source explicitly —
351
+ nothing is ever selected by mtime or "latest":
352
+
353
+ ```bash
354
+ campaigns-os qa promote \
355
+ --packet campaign-runtime.build.json \
356
+ --verdict qa-output/<map-id>/<run-id>.json \
357
+ --json
358
+ ```
359
+
360
+ `qa promote` validates the source before writing, replaces the sidecar
361
+ atomically, refuses the destination sidecar as its own source, and leaves the
362
+ source verdict byte-identical.
363
+
364
+ ### Verdict schema and trust semantics
365
+
366
+ The verdict shape is contracted as `campaigns-os-qa-verdict/v0`
367
+ ([`schemas/campaigns-os-qa-verdict.v0.schema.json`](../schemas/campaigns-os-qa-verdict.v0.schema.json)),
368
+ with the committed sidecar projection's guarantees pinned separately in
369
+ [`schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json`](../schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json).
370
+ Both describe the same emitted `schema_version` literal `"1.0"` — one contract,
371
+ projected two ways, never a second lineage. The emitted literal predates the
372
+ slash-versioned naming convention and the portal receiver validates the same
373
+ literal, so changing it is a breaking shape change. Additions to v0 are
374
+ expected; consumers must tolerate unknown fields.
375
+
376
+ Two identity fields are easy to misread. `spec_hash` on the verdict is the
377
+ CampaignSpec **material** hash — the canonical semantic identity that ignores
378
+ formatting and the declared volatile metadata — and pairs with the Assembly
379
+ Report's `identity.spec_material_hash` and the Build Context's
380
+ `spec.material_hash`, not with the report's `identity.spec_hash`, which is the
381
+ raw-byte digest of the spec file (the two meanings are deliberate; see
382
+ [docs/migration-sidecar-bundle.md](./migration-sidecar-bundle.md)).
383
+ `campaign_ref_id` is copied from the CampaignSpec's `campaign.ref_id` and
384
+ identifies the platform campaign the spec was exported from, not this build:
385
+ two specs exported from one platform campaign share it by design, and it is
386
+ `null` when the spec carries none. Use `campaign_slug` (the Map ID) and
387
+ `public_route_slug` to tell builds apart.
388
+
389
+ **Trust is stamped by the receiver, never by this CLI.** The QA portal
390
+ receiver accepts verdict posts publicly (after shape/size/rate checks) and
391
+ classifies each submission at ingest: a post carrying the ingest credential is
392
+ stored with `trusted: true` / `trust_level: "shared_secret"` / a `verified_at`
393
+ instant; a post without it — an **anonymous submission** — is stored with
394
+ `trusted: false` / `trust_level: "anonymous"` / `verified_at: null`. Those
395
+ three fields therefore appear only on records read back from the receiver;
396
+ verdicts this runner writes locally never carry them.
397
+
398
+ `trusted: false` means exactly this: **the record is shape-valid but
399
+ unattributed — anyone on the internet could have submitted it.** Schema
400
+ validity is not trust; a forged verdict passes every shape check by design.
401
+ Consequently:
402
+
403
+ - **Every downstream consumer of verdict readback MUST filter on `trusted` or
404
+ segregate untrusted records** (render them in a visibly separate, untrusted
405
+ lane — never mixed into launch evidence, QA history, or agent readback as
406
+ peers of verified runs).
407
+ - Campaigns OS itself enforces this at its own readback chokepoints:
408
+ `qa promote` (and any sidecar projection) refuses a source verdict stamped
409
+ `trusted: false` — an untrusted record can never be laundered into the
410
+ committed `.campaign-runtime/qa-verdict.json` — and `run-record`'s automatic
411
+ QA-verdict inference excludes untrusted records from the run's QA evidence.
412
+ The test suite carries a forged, shape-valid, untrusted verdict as a
413
+ negative control for both.
414
+
415
+ Endpoint authentication and attribution hardening are deliberately separate
416
+ work that lands with the receiver's connection contract. This section
417
+ documents the semantics of the stamps the receiver already applies.
418
+
419
+ Add `--browser --test-order common` for the normal proof pass: first-party
420
+ Playwright browser checks plus the default typed-card order sample. If the
421
+ browser binary is missing, the CLI will prompt you to run
422
+ `npm run qa:install-browser`:
423
+
424
+ ```bash
425
+ npm run campaigns-os -- qa run \
426
+ --packet campaign-runtime.build.json \
427
+ --base-url https://preview.example.com/campaign/ \
428
+ --browser \
429
+ --test-order common
430
+ ```
431
+
432
+ The browser pass renders each live page in Chromium, captures browser console
433
+ errors, page errors, and failed requests, verifies rendered upsell controls, and
434
+ inspects checkout payment field mounts. For checkout pages with a locked
435
+ template family, it also runs `browser-commerce-structure` against any
436
+ machine-checkable `agentContract.qaStructure` selectors in the commerce surface
437
+ catalog. If the family contract is silent, the assertion returns
438
+ `manual_review`, not `pass`; if declared required structure is missing, it
439
+ soft-fails with warning severity so the verdict becomes `ready_with_exceptions`.
440
+ Promoted template families must also have
441
+ `contracts/template-brand-contract.<family>.v0.json`; QA emits a blocker if the
442
+ selected family is missing its brand/residue/pricing contract instead of
443
+ silently skipping starter-palette and pricing checks.
444
+ It is owned by this package through the `playwright` dependency; QA must not
445
+ rely on external browser skills or local agent tooling.
446
+
447
+ Payment-chrome residue (`template-residue:<page>:payment-chrome:<method>`) is
448
+ keyed on the contract's `default_residue.payment_chrome` selectors and asset
449
+ basenames for every method the CampaignSpec does not list. A visible selector is
450
+ residue. A referenced `.svg` asset is fetched as the page serves it and the raw
451
+ bytes are hashed against the shipped starter hash the shared-commerce contract
452
+ records (`payment_chrome.asset_sha256`, taken from the starter-templates commit
453
+ in `asset_pin.sha`): the shipped bytes are the untouched starter strip and count
454
+ as residue, listed first in the row and tagged inline
455
+ (`residue found: upsell-payment-logos.svg [starter], .payment-method__icon--paypal-logo`;
456
+ `evidence.starter_assets` carries the same basenames). Different bytes that no
457
+ longer name the method are an asset edited in place, reported as `manual_review`
458
+ (`edited in place: ... confirm the removal was intended, and remove or rename the
459
+ asset`) so no repair deletes an asset already dealt with. An asset that cannot
460
+ be read (a raster, a 404, a timed-out or oversized read) stays residue. Every
461
+ asset the contract lists must carry a hash — a missing or malformed entry fails
462
+ contract load naming the asset — so the markup test only decides for a contract
463
+ that carries no `asset_sha256` map at all; the shipped
464
+ `upsell-payment-logos.svg` draws its marks as bare path data and names no method
465
+ in its markup, which is why the bytes decide.
466
+
467
+ Fresh Build Packets record the proof contract in `qa.proof_policy`, and
468
+ Assembly Reports mirror it at `report.proof_policy`. The important fields are
469
+ `browser_qa_required`, `typed_card_depth`, `order_path_depth`,
470
+ `localhost_development_domain_allowed`,
471
+ `non_localhost_origin_allowlist_required`, and `operator_approval_state`.
472
+ Agents should update proof state in artifacts instead of renegotiating browser
473
+ QA or typed-card depth in chat.
474
+
475
+ Campaign Build Brief `qa_policy` is business expectation metadata, not a
476
+ direct runner gate. Normalized briefs mark it as
477
+ `documented_expectation`; the enforced proof contract remains
478
+ `qa.proof_policy` and `report.proof_policy`.
479
+
480
+ For SDK-owned runtime pages such as checkout, upsell, downsell, and receipt,
481
+ the browser pass also opens a separate instrumented view with `?debugger=true`
482
+ and verifies that the Campaign Cart debugger overlay and selector controls
483
+ mount. This debugger check is separate from the normal user-flow page load and
484
+ test-order path so shopper behavior is not altered by QA instrumentation.
485
+
486
+ Routing meta tags are evaluated in runtime-resolved form. If the spec carries `next-success-url: upsell/`, the deployed page should emit a campaign-root path such as `/roadside-ready/upsell/` so the SDK does not resolve the redirect from the site root.
487
+
488
+ Upsell accept/decline route checks accept rendered SDK controls as static evidence when there is no `<a href>`: `data-next-upsell-action="add"` for accept and `data-next-upsell-action="skip"` for decline. The browser walkthrough still needs to click the actual controls.
489
+
490
+ ## Why a finding is there: the cause label
491
+
492
+ A run that surfaces eleven findings, none of them caused by the change under
493
+ test, reads on the report exactly like a run that broke eleven things. So every
494
+ finding carries a **cause class**, and the report leads with the tally.
495
+
496
+ | Class | Means |
497
+ |---|---|
498
+ | `caused_by_change` | This finding was not in the previous run for this campaign, or it was there with a different status. |
499
+ | `pre_existing` | The identical finding, with the identical status, was in the previous run. The change under test did not introduce it. |
500
+ | `test_environment` | The runner itself classified this as an environment outcome, not a campaign defect: the order-creation budget safety stop, or a `<leg>:runner` capture failure. |
501
+ | `upstream_drift` | An already-detected disagreement between the SDK version the CampaignSpec pins and the version the target carries (`page_kit.sdk_version`, `page_kit.sdk_version.repo_newer`, `page_kit.sdk_version.waived`, `page_kit.sdk_version.spec_conflict`). |
502
+ | `unknown` | No class could be assigned from recorded data. `cause_reason` says why. |
503
+
504
+ Two fields ride each finding: `cause` (one of the five) and `cause_reason` (a
505
+ short machine-readable reason). Both are additive and optional — verdicts and
506
+ doctor output emitted before this existed carry neither, and absence must never
507
+ be read as "nothing was caused by the change".
508
+
509
+ ### The comparison rule
510
+
511
+ Exactly one comparison, against exactly one earlier run:
512
+
513
+ 1. **Find the previous run.** The most recent Run Record under the Build
514
+ Packet's `.campaign-runtime/run-records/` whose `identity.map_id` matches
515
+ this campaign. Only the first match counts — walking further back to find a
516
+ record that happens to carry usable evidence would compare this run against
517
+ a non-adjacent one and report anything introduced in between as
518
+ pre-existing.
519
+ 2. **Read that run's findings.** For QA, through that Run Record's **last**
520
+ `qa_verdict` artifact reference. A run session that needed repair and
521
+ re-test carries one reference per attempt in session order, so the first is
522
+ typically the blocked attempt that triggered the repair; comparing against
523
+ it would report a defect that was fixed before that run closed, and
524
+ reintroduced now, as pre-existing. The last reference is the verdict the
525
+ run actually closed on. This does not widen the boundary — it is still the
526
+ final attempt of exactly one earlier run, never a merged view across runs.
527
+ When that reference is `external:qa_verdict` — the record's spelling for a
528
+ verdict written outside the packet directory, the ordinary case whenever
529
+ `assembly.target_repo` is not the packet's own directory — the verdict is
530
+ located by its recorded digest under the target repo's `qa-output/`. The
531
+ committed `.campaign-runtime/qa-verdict.json` sidecar is not a stand-in:
532
+ it is a projection, so its digest cannot match, and the record stores no
533
+ verdict run id to tie it to the referenced attempt — comparing against a
534
+ projection of some other attempt would report a reintroduced finding as
535
+ pre-existing.
536
+ For doctor, from the Run Record's `observations.doctor.error_codes` /
537
+ `warning_codes`.
538
+ 3. **Classify.** Environment and upstream drift are decided first, from the
539
+ finding itself, and win outright — a Chromium capture failure that also
540
+ happened last time is still not the campaign's fault. Everything else is
541
+ compared by fingerprint: same fingerprint and same status is
542
+ `pre_existing`; absent, or present with a different status, is
543
+ `caused_by_change`.
544
+
545
+ The fingerprint is the identity the artifact already uses. For a QA assertion
546
+ that is `family | id | page` — the same identity the exceptions projection
547
+ carries, deliberately **without** the URL, so a campaign QA'd locally and then
548
+ against its published deploy is compared like for like. For a doctor issue it
549
+ is the `code`, because the code is what the Run Record stores; two distinct
550
+ violations sharing a code are one finding to this comparison.
551
+
552
+ ### When the answer is `unknown`
553
+
554
+ `cause_reason` names the gap, and never guesses past it:
555
+
556
+ | `cause_reason` | What happened |
557
+ |---|---|
558
+ | `no_prior_run` | No Run Record for this campaign under the packet directory — including every packet-less run (`--site`, a raw map id), which has no Run Record home. |
559
+ | `prior_run_without_qa_verdict` | The previous Run Record carries no QA verdict artifact reference at all. |
560
+ | `prior_run_verdict_unreadable` | It references one by path, but the file is gone or unparseable. |
561
+ | `prior_run_verdict_unlocated` | It references one as `external:qa_verdict`, but no verdict matching that reference could be located under the target repo's `qa-output/` — nothing there hashes to the recorded digest, the reference carries no digest, or no target repo was known to search. |
562
+ | `prior_run_without_doctor_observations` | The previous Run Record carries no doctor observations. |
563
+
564
+ Only the first of those means "run again and it will improve". The other four
565
+ say a previous Run Record **does** exist and its evidence is missing or
566
+ unreadable, which a second run will not fix on its own — so the report names
567
+ that record rather than telling you to wait for one.
568
+
569
+ The practical consequence: **the first run on a campaign labels everything
570
+ `unknown`.** There is nothing to compare against, and that is the honest
571
+ answer. The comparison starts working on the second run, once a Run Record
572
+ exists — so `campaigns-os run-record` is what makes the next run's labels
573
+ meaningful.
574
+
575
+ ### Where the labels appear
576
+
577
+ - `qa run` — `cause` / `cause_reason` on every finding assertion and on every
578
+ derived exception; `cause_summary` (`{schema_version, surface, total, counts,
579
+ prior_run_id, prior_qa_attempt_run_id, comparison}`) on the verdict; a summary line plus a
580
+ per-finding list on the human report. Passing assertions carry no cause: a
581
+ pass has no cause to explain.
582
+ `prior_run_id` is the previous **Run Record's** id on both surfaces, so the
583
+ two agree on which run was compared; the QA summary also carries
584
+ `prior_qa_attempt_run_id`, the final attempt within that record it actually
585
+ read, and its summary line says so.
586
+ - The committed QA verdict sidecar — both fields survive the projection, and
587
+ so does `cause_summary`. They are a short enum and a reason code: no URL, no
588
+ order reference, no capture body.
589
+ - Every doctor result — `cause` / `cause_reason` on every error and warning and
590
+ a `cause_summary` on the output, applied where the doctor result is produced
591
+ rather than in one command. Four producers persist
592
+ `.campaign-runtime/doctor-output.json` (`doctor --write`, `next`, `start` /
593
+ `build`, and the QA stage refresh), so the retained artifact keeps its labels
594
+ whichever one wrote it last: running QA after doctor no longer strips them.
595
+ Each of them stamps the sidecar `generated_by` with its own name (`doctor`,
596
+ `next`, `start`, `build`, `qa run`), beside `generated_at`, so a retained
597
+ sidecar always says which command wrote it — the way a stale stamp already
598
+ names its command in `stale_marked_by`.
599
+ The `doctor` human report adds the summary line and a cause tag after each
600
+ issue line; the existing `[code] message` shape is unchanged.
601
+ Non-packet doctor (`--built` / `--site`) has no Run Record home and is not
602
+ annotated.
603
+
604
+ ## Offer Application QA
605
+
606
+ When a checkout page declares `exit_intent.enabled`, QA should exercise the
607
+ accept path as a checkout runtime behavior:
608
+
609
+ - trigger or open the exit-intent surface in the rendered checkout
610
+ - accept the mapped offer
611
+ - verify the mapped code becomes active in cart state
612
+ - verify bundle selectors, totals, order summary, and discount rows reprice from
613
+ SDK/API state
614
+ - verify any code-specific labels gated by `cart.hasCoupon("CODE")` render only
615
+ after the code is active
616
+
617
+ When a checkout page declares `promo_code_input.enabled`, QA should enter the
618
+ mapped `offer_code` and verify the same active-code, repricing, discount row,
619
+ and conditional presentation evidence. Missing promo-code input is a blocker
620
+ when CampaignSpec, the source design, or the user explicitly declared it as part
621
+ of the build.
622
+
623
+ QA evidence redacts checkout request bodies and generated QA emails. Verdict artifacts
624
+ keep method, URL, response summaries, order refs, line-item summaries, and card last4,
625
+ but they should not contain full customer address/payment payloads.
626
+
627
+ QA runs **publish to the QA portal by default** — they appear in the Campaign Map
628
+ QA tab and the run picker, and the command prints the portal link. No flag needed.
629
+ Pass `--no-post-verdict` (or `--local-only`) for offline / dev / CI runs that should
630
+ stay local-only; publishing never fails the QA run if the portal is unreachable.
631
+
632
+ The default rides the telemetry consent seam: with consent off
633
+ (`CAMPAIGNS_OS_TELEMETRY=off` or `campaigns-os telemetry off`), a run whose spec
634
+ came from a **local file** (client projects, fixtures, local shakeouts) stays
635
+ local-only, and the output names the destination plus the opt-in
636
+ (`--post-verdict`, or `campaigns-os telemetry on`). Portal-managed campaigns —
637
+ spec resolved from the portal for the run — keep publish-by-default regardless
638
+ of consent: those verdicts are the QA tab's product surface, not telemetry.
639
+ Explicit flags always win in both directions.
640
+
641
+ ```bash
642
+ npm run campaigns-os -- qa run \
643
+ --packet campaign-runtime.build.json \
644
+ --base-url https://preview.example.com/campaign/
645
+ ```
646
+
647
+ ### Publish a stored verdict (`qa publish`)
648
+
649
+ "Run local, publish when clean" is one command, not a rerun. A run kept local
650
+ with `--no-post-verdict` writes the same full verdict under `qa-output/` and
651
+ the same committed sidecar as a publishing run; `qa publish` posts that stored
652
+ verdict to the QA portal through the rail `qa run` uses, without re-running
653
+ QA — and so without placing another typed-card order set (the case in #328
654
+ placed the whole set twice).
655
+
656
+ ```bash
657
+ # 1. local first: the full run, kept off the portal
658
+ npm run campaigns-os -- qa run \
659
+ --packet campaign-runtime.build.json \
660
+ --base-url https://preview.example.com/campaign/ \
661
+ --browser --test-order common --no-post-verdict
662
+
663
+ # 2. clean: publish the verdict that run wrote — no re-run, no orders
664
+ npm run campaigns-os -- qa publish --packet campaign-runtime.build.json
665
+ ```
666
+
667
+ Which verdict goes out: `--verdict <path>` names a file and is honoured as
668
+ given. Without it, the committed `.campaign-runtime/qa-verdict.json` names the
669
+ run, and the full verdict that run wrote at
670
+ `<target-repo>/qa-output/<map-id>/<run-id>.json` is preferred (it carries the
671
+ evidence the portal shows); a run that wrote under `--output-dir <dir>` is
672
+ found by passing the same `--output-dir` to `qa publish`. When only the
673
+ projection is on disk, the projection is what is published and the output
674
+ says so (`source_kind: sidecar_projection`).
675
+
676
+ Before anything is sent, the command refuses — exit `2`, nothing posted, no
677
+ order placed — with a named `refusal.code`:
678
+
679
+ | `refusal.code` | What it means |
680
+ |---|---|
681
+ | `spec_hash_mismatch` | The verdict's `spec_hash` is not the packet's current spec (`spec.local_path`, hashed the way every spec-identity check hashes it, so `sha256:` prefix and case do not matter). A verdict for a spec that has since changed is not evidence about the current one: re-run `qa run`, which publishes by default. The result carries both hashes. |
682
+ | `spec_hash_absent` | The verdict carries no `spec_hash`. Re-run `qa run`. |
683
+ | `already_published` | The run's Run Record records this verdict's `run_id` as published (by the run itself, or by an earlier `qa publish`). Pass `--republish` to post it again; the existing portal link is in the output either way. |
684
+ | `verdict_untrusted` | The verdict is stamped `trusted: false` (a receiver-classified anonymous submission); the same chokepoint `qa promote` holds. |
685
+ | `campaign_mismatch` | The verdict's `campaign_slug` is neither the packet's map id nor its public route slug. |
686
+ | `verdict_missing` / `verdict_unreadable` / `verdict_invalid` | No stored verdict, unreadable JSON, or one that fails local validation. |
687
+ | `order_flags_refused` | `--test-order`, `--browser`, `--max-order-creations` or another order-run flag was given. `qa publish` never places orders, and it says so rather than silently ignoring the flag. |
688
+
689
+ The post is classified by what the portal answered, exactly as a Run Record
690
+ remit is: a parsed 2xx is `stored`, a 409 is `already_stored` (an ok — the
691
+ portal already holds the run id), a 2xx with a non-JSON body is
692
+ `ok_unparsed_ack`; any other non-2xx is `refused` and no answer is
693
+ `transport_error`, both exit `1` with the local verdict untouched. Exit `0`
694
+ prints the portal link.
695
+
696
+ The outcome lands on the run's Run Record as the `qa_verdict_publish` block —
697
+ `verdict_run_id`, `publisher` (`qa run` or `qa publish`), `state`
698
+ (`skipped` / `ok` / `failed`), `result` in the remit vocabulary, `base_kind`,
699
+ `published_at` — on the record whose `qa_verdict` artifact references the
700
+ verdict under the packet's campaign. `qa run` records its own publish (or
701
+ its `--no-post-verdict` skip) the same way when the run session closes, which
702
+ is what `already_published` reads. A stored `ok` is never downgraded: a
703
+ `--republish` whose send fails leaves the block as written and reports the
704
+ failure on the command's envelope only. When no record references the
705
+ verdict, the publish still happens and the output says the outcome is
706
+ unrecorded. Nothing under `--json` or in the text output names an order: the
707
+ result carries `orders_placed: 0` by construction.
708
+
709
+ ## What a published anonymous record is
710
+
711
+ A published verdict and a remitted Run Record are durable, but they are not
712
+ attributed. The public runner carries no ingest credential, so the receiver
713
+ stamps what it gets as `trusted: false` / `trust_level: "anonymous"` /
714
+ `verified_at: null`, and a remitted Run Record lands in tenant scope without
715
+ naming who produced it. Read the stamps before you rely on the record.
716
+
717
+ **An anonymous published record is an unverified submitted claim.** It records
718
+ what the submitter reported — not that a run happened, and not that the
719
+ artifacts in it reflect real observations. The receiver accepts posts publicly
720
+ after shape, size, and rate checks; it does not execute anything, witness
721
+ anything, or verify anything it is told. Its contents — the step ladder and its
722
+ per-step statuses, the assertions and their severities, order refs and
723
+ line-item summaries, console and request evidence, timestamps — are claims in
724
+ the submission, and they are exactly as good as the submitter.
725
+
726
+ Nothing in such a record establishes even that it was produced by the toolkit.
727
+ Anyone on the internet can post a shape-valid verdict, and a fabricated one
728
+ passes every check the schema makes; `src/qa-verdict-schema.test.mjs` carries a
729
+ forged, shape-valid, untrusted verdict as a standing negative control precisely
730
+ to keep that fact from being forgotten.
731
+
732
+ **So: any launch decision needs independent execution evidence.** The
733
+ attributed local artifacts of the run itself — the emitted verdict in the
734
+ operator's own checkout, the committed `.campaign-runtime/qa-verdict.json`, the
735
+ local Run Record, CI or session logs — are what establish that a run happened
736
+ and what it saw. A published anonymous record points at those; it does not
737
+ substitute for them, and it is not verified launch evidence by the portal's
738
+ standard. Do not present one as such — not in a handoff, not in a launch
739
+ readiness claim, not to a merchant. Campaigns OS holds the same line at its own
740
+ readback chokepoints: `qa promote` refuses an untrusted source verdict, and
741
+ `run-record`'s QA-verdict inference excludes untrusted records.
742
+
743
+ Attributed publishing — an ingest credential a named operator's runner can
744
+ carry, so the receiver can stamp `trusted: true` — is tracked as
745
+ campaigns-os#329 and is not available today.
746
+
747
+ ## Cart-state verification: do not trust `cartLines`
748
+
749
+ When QA needs to confirm the cart actually holds the expected items, **do not read
750
+ `next.getCartData().cartLines`**. That field is currently always an empty array
751
+ regardless of cart contents — `getCartData()` returns `cartStore.enrichedItems`,
752
+ which is initialized `[]` and never populated; the real line items live in the
753
+ store's `items` / `summary.lines`. See
754
+ [NextCommerceCo/campaign-cart#36](https://github.com/NextCommerceCo/campaign-cart/issues/36).
755
+ Verified live on deployed checkouts (SDK 0.4.18 and 0.4.24): a correctly committed
756
+ bundle shows populated internal `items` while `cartLines` stays `[]`. An assertion
757
+ like `cartLines.length > 0` therefore **silently passes on an empty array** — a
758
+ false-positive "cart populated" verdict.
759
+
760
+ Use the signals this runner already relies on instead:
761
+
762
+ - **Committed cart (truth):** the typed-card test-order order read-back — the
763
+ persisted order's receipt line items (`/api/v1/orders` response). This is the
764
+ proof path the test-order flow uses. For an in-page check, read the
765
+ `cart:updated` event payload (`items` / `summary.lines`).
766
+ - **In-flight selection (pre-commit):** rendered DOM evidence —
767
+ `[data-next-bundle-card]` selected state and visible prices — or the bundle
768
+ selector's `_getSelectedBundleItems()`. Subtotal/totals reflect the previewed
769
+ selection and are not proof that a line committed.
770
+
771
+ This is enforced by `scripts/check-cart-readiness-contract.mjs` (part of
772
+ `npm run check`), which fails if QA source reaches for `cartLines`. Relax or
773
+ retire that guard once #36 ships and `cartLines` is populated.
774
+
775
+ ## Analytics correctness (inventory, then receipt Purchase)
776
+
777
+ Analytics correctness has two deliberately separate evidence phases in one QA
778
+ run:
779
+
780
+ 1. The campaign-root visit inventories declared providers, containers, pixels,
781
+ and other observable tags. It does not prove or disprove Purchase, even if a
782
+ stray Purchase-shaped event appears there.
783
+ 2. The existing canonical typed-card order run supplies Purchase evidence. For
784
+ each planned order, the topology classifier must recognize the final URL as
785
+ that plan's receipt, then the runner waits the full `--analytics-settle`
786
+ window (default `5000` ms) within the order deadline and assesses the events
787
+ and tag fires emitted across every document the path loaded, checkout
788
+ through receipt. It does not replay the browser path or place a second
789
+ order.
790
+
791
+ The receipt is the qualification point, not the measurement point. The SDK
792
+ raises `dl_purchase`, and the outbound Purchase it drives, on the **first page
793
+ opened with `?ref_id=`** that fetches the order back — the upsell page on a
794
+ funnel that has one, the receipt only when nothing sits between checkout and
795
+ receipt — and then remembers the transaction id so the receipt does not report
796
+ it again (#392). So a receipt-qualified order passes when Purchase reached the
797
+ dataLayer, an outbound Meta Purchase, or an outbound GA4 Purchase on **any**
798
+ page of its post-checkout journey; a receipt-only rule is a structural false
799
+ negative on every funnel with an offer page. Every planned receipt-qualified
800
+ order must emit an effective Purchase for a pass. Each `evidence.receipts[]`
801
+ entry records `scope` (`journey`; `receipt` when only the receipt document
802
+ was captured; `null` on an unmeasured entry, where `signals`, `receipt_signals`
803
+ and `fired_on` are null too), the judged `signals`, the receipt document's own
804
+ `receipt_signals` (journey scope only — a receipt-scoped judgement has no
805
+ second reading, so it is `null` there), and `fired_on` (`receipt` or
806
+ `earlier-page`, `null` when nothing fired), so a reader can tell which
807
+ document fired without the raw capture. Migration parity reads
808
+ the same journey capture through its own leg.
809
+
810
+ - A missing attempt or topology-unrecognized final page is
811
+ `MANUAL_REVIEW`/`WARN`.
812
+ - A recognized receipt with no effective Purchase is `FAIL`/`BLOCKER`.
813
+ - A capture, unreadable-page, or settle-deadline error on a recognized receipt
814
+ is an explicit, non-waivable `FAIL`/`BLOCKER`; it is never normalized to a
815
+ zero-signal capture.
816
+ - The `analytics-correctness:purchase-fires` waiver applies only to a genuine
817
+ recognized-receipt/no-signal failure. It is inert for passes, manual-review
818
+ paths, and capture/settle errors.
819
+ - Analytics-off and legacy API-only order paths emit no receipt Purchase proof.
820
+
821
+ `purchase-fires` answers "did a Purchase reach a provider" and needs a declared
822
+ analytics block to gate. The SDK's own data layer is a separate, always-on
823
+ reading taken on the same order — see [Purchase data layer](#purchase-data-layer-dl_purchase)
824
+ under Test Orders.
825
+
826
+ ## Analytics parity (dataLayer / GTM)
827
+
828
+ The analytics-parity leg proves the live **dataLayer event stream + GTM/pixel
829
+ tag-fires** match after a migration cutover — the leg repo scans can't cover,
830
+ because runtime-injected GTM and remote `campaign.js` pushes are invisible to a
831
+ static scan. Migration doctrine: **no cutover on a non-zero analytics diff.**
832
+
833
+ It is opt-in. Supply a **baseline** (the legacy live funnel) and a **candidate**
834
+ (the migrated preview); the runner captures both with Playwright and diffs them:
835
+
836
+ ```bash
837
+ npm run campaigns-os -- qa run \
838
+ --packet campaign-runtime.build.json \
839
+ --base-url https://preview.example.com/campaign/thank-you/ \
840
+ --analytics-candidate https://preview.example.com/campaign/thank-you/ \
841
+ --analytics-baseline https://legacy.example.com/campaign/thank-you/
842
+ ```
843
+
844
+ This receipt-to-receipt parity example names `--analytics-candidate`
845
+ explicitly. When that flag is omitted, the candidate is the campaign identity's
846
+ composed root (`public_route_slug` plus `route_root`), not the raw
847
+ `--base-url` value.
848
+
849
+ | Flag | Meaning |
850
+ |---|---|
851
+ | `--analytics-baseline <url>` | Legacy funnel URL to capture as the parity baseline (enables the leg) |
852
+ | `--analytics-candidate <url>` | Migrated URL to capture; defaults to the identity-composed campaign root |
853
+ | `--analytics-hosts a,b` | Extra host substrings to treat as analytics tag-fires (Everflow is built in) |
854
+ | `--analytics-settle <ms>` | Wait after analytics page loads and after a recognized typed-order receipt for async tags to fire (default 5000); receipt settling must fit inside the order deadline |
855
+
856
+ > The analytics legs drive a headless **Playwright** browser (like `--test-order`),
857
+ > so they need the package-owned browser installed (`npm run qa:install-browser`)
858
+ > and outbound network — they cannot run in a no-outbound sandbox.
859
+
860
+ Point both at the **thank-you / receipt page** for the highest-value `dl_purchase`
861
+ check, or drive the same offer through each funnel so client-fired values line up.
862
+
863
+ What the diff asserts (BLOCKER unless noted):
864
+ - `purchase-present` — candidate fires a purchase event.
865
+ - `purchase-value` / `purchase-currency` — match the baseline's **client-fired**
866
+ value (compared client-vs-client; never vs a backend total, since tax is
867
+ computed backend and is not in the client value on headless checkouts).
868
+ - `purchase-transaction-id` — present (not equal — different orders have different ids).
869
+ - `capi-dedup` — the Meta `Purchase` fire carries an `eventID` keyed on the order id.
870
+ - `carryover:<provider>:<id>` — **WARN** when a container/pixel that fired on the
871
+ baseline (GTM, Meta, Everflow, GA4, …) is **absent on the candidate** — a likely
872
+ attribution regression flagged for human review, not an auto-block.
873
+
874
+ For a real SDK 0.4 migration example that required typed-card post-purchase
875
+ traversal, persisted-order price verification, receipt-context analytics, and
876
+ independent order readback, see
877
+ [SDK 0.4 Migration Proof Case Study](sdk04-migration-proof-case-study.md).
878
+
879
+ ## Parity capture (fixture-driven migration proof)
880
+
881
+ Parity capture codifies the migration **PARITY-QA** leg: one declared offer is
882
+ driven through the candidate funnel by a typed-card test order while analytics
883
+ are captured across the checkout and post-purchase navigation. The persisted
884
+ order and client event stream are then assessed against a versioned fixture
885
+ corpus.
886
+
887
+ Run the live candidate traversal with a fixture scenario. A legacy analytics
888
+ baseline is optional; add `--baseline` when the migration cell requires a
889
+ candidate-vs-baseline diff:
890
+
891
+ ```bash
892
+ npm run campaigns-os -- qa parity \
893
+ --fixture fixtures/parity/example-sdk04-offers.json \
894
+ --scenario root-accessory-oto50 \
895
+ --base-url https://preview.example.com/campaign/ \
896
+ --baseline https://legacy.example.com/campaign/ \
897
+ --no-post-verdict
898
+ ```
899
+
900
+ Every live run writes
901
+ `qa-output/<campaign-slug>/<runId>.parity-bundle.json` beside the verdict. The
902
+ bundle contains the order readback, candidate analytics capture, and optional
903
+ baseline capture. Replay that exact evidence without Playwright:
904
+
905
+ ```bash
906
+ npm run campaigns-os -- qa parity \
907
+ --fixture fixtures/parity/example-sdk04-offers.json \
908
+ --scenario root-accessory-oto50 \
909
+ --parity-order-json qa-output/example-sdk04-offers/<runId>.parity-bundle.json \
910
+ --no-post-verdict
911
+ ```
912
+
913
+ The required negative control is a copy of the bundle doctored to restore the
914
+ dropped-voucher line total. It must fail the persisted-line blocker:
915
+
916
+ ```bash
917
+ npm run campaigns-os -- qa parity \
918
+ --fixture fixtures/parity/example-sdk04-offers.json \
919
+ --scenario root-accessory-oto50 \
920
+ --parity-order-json qa-output/example-sdk04-offers/<runId>.dropped-voucher.parity-bundle.json \
921
+ --no-post-verdict
922
+ ```
923
+
924
+ **A harness that cannot fail the bug it guards is not proven.** Preserve the
925
+ passing replay and the dropped-voucher failing replay as paired migration
926
+ evidence.
927
+
928
+ Fixture essentials:
929
+
930
+ - `scenarios` declares the selectable regression cases; live capture accepts a
931
+ `funnel_offer` scenario.
932
+ - `checkout_path` and `upsell_route` bind the typed-card traversal to the exact
933
+ candidate surfaces.
934
+ - `expected_order_readback.line_item.price_field` names the persisted field to
935
+ assess; do not infer a different price field at runtime.
936
+ - `expected_purchase.value` may be `null`: the named client event must still
937
+ carry a finite value, while the offer amount is proven by persisted-line
938
+ readback.
939
+ - `analytics_contract` declares the expected providers and events so missing
940
+ analytics gate at blocker severity instead of the no-contract INFO path.
941
+ - Credentials are never fixture data. Credential lint permits environment-name
942
+ indirection such as `api_key_env: "QA_CAMPAIGNS_API_KEY"`; literal keys,
943
+ tokens, passwords, and other credential values are rejected.
944
+ - `campaign.slug` is a single path-safe segment (`a-z`, `0-9`, dot, dash,
945
+ underscore). It names the output subdirectory, so separators and `..` are
946
+ rejected at load and the writer refuses any slug that would escape
947
+ `--output-dir`.
948
+ - `baseline_url` is optional and must be an `http(s)` URL. A fixture-supplied
949
+ baseline only receives `--auth-cookie` when it is same-origin with the
950
+ candidate; name the baseline with `--baseline` to authorize sending the
951
+ preview credential to another host.
952
+
953
+ ## Test Orders
954
+
955
+ Test Orders use **global test cards** that work on any live store and integration.
956
+ They **bypass the payment gateway and create no transactions** (and no fulfillment),
957
+ so they are safe to run any time and need **no permission flags, packet policy,
958
+ merchant sandbox routing, or test-order approval** — you just pick a mode. They leave a small,
959
+ easy-to-clean footprint: Test orders are deletable in bulk, and the resulting
960
+ Customer record is reused (see the test email note below) rather than multiplied.
961
+
962
+ Canonical proof is typed-card, browser-driven checkout automation. The QA runner
963
+ opens the deployed campaign checkout with Playwright, selects the intended cart
964
+ with rendered campaign controls, fills the customer/shipping form, types the test
965
+ card into the active hosted payment iframes, and clicks the real checkout submit
966
+ button. A hand-built backend API order does not prove the deployed
967
+ checkout/upsell surfaces.
968
+
969
+ Order-creation proof is read-back tolerant: the live order-create network
970
+ observation is best-effort (a fast post-submit navigation can drop the capture),
971
+ so when the create request was missed but the page redirected with a `ref_id`
972
+ and the order read-back returns the persisted order, the path passes and the
973
+ verdict records an `order_create_observation` note — mirroring the
974
+ accepted-upsell rule. An observed create with a non-2xx status still fails.
975
+
976
+ ```bash
977
+ npm run campaigns-os -- qa run \
978
+ --packet campaign-runtime.build.json \
979
+ --base-url https://preview.example.com/campaign/ \
980
+ --test-order common
981
+ ```
982
+
983
+ The default mode is **`common`** (also what bare `--test-order` runs): at most
984
+ four shapes from the selected checkout's declared topology — the checkout
985
+ baseline, first-offer `accept` and `decline` when `expected_next_url` reaches an
986
+ upsell/downsell, and the shortest declared path that actually reaches a
987
+ receipt/thank-you page. The receipt path is deduplicated when it is already
988
+ `accept` or `decline`; Campaigns OS never invents a receipt path from offer
989
+ count alone. This is the everyday QA sample.
990
+
991
+ Other modes: `checkout` (base order redirect only), `accept`/`decline` (click the
992
+ rendered control on the first upsell page), `both` (two fresh orders for those
993
+ first-page paths), explicit accept/decline paths such as `accept-decline-accept`
994
+ for a targeted matrix, and **`full`** — every actual terminal path found by
995
+ walking the selected checkout's `expected_next_url` and each reachable offer's
996
+ `expected_accept_url` / `expected_decline_url`. A branch stops at a receipt,
997
+ thank-you page, or genuine cross-origin handoff, so shortcut and uneven branches
998
+ keep their real lengths. The walk is deterministic and cycle-safe. `full`
999
+ refuses to start the browser if a reachable branch cycles, omits a route, points
1000
+ at an undeclared same-origin page, or otherwise has no recognized terminal.
1001
+ Use `full` when you explicitly want exhaustive topology proof. Bundle/quantity
1002
+ and bump coverage come from `--cart` and `--select-package`, or spec-driven from
1003
+ **`tiers`** (below).
1004
+
1005
+ At runtime, a planned path may stop early only after the browser reaches a
1006
+ terminal URL recognized in that selected topology. Missing accept/decline
1007
+ controls on an ordinary or unknown page remain blockers; they are not treated
1008
+ as evidence that a receipt was reached. Cross-origin handoffs count as terminal
1009
+ navigation, but not as Campaigns OS receipt rendering or persisted-receipt proof.
1010
+
1011
+ ### Purchase-proof coverage (`--test-order off` is a diagnostic)
1012
+
1013
+ A QA run finalizes a verdict and records a terminal stage status whether or not
1014
+ any order path ran. That made `--test-order off` indistinguishable, downstream,
1015
+ from proof that a purchase worked: the report showed a completed QA stage, and
1016
+ `next` advanced to `done` on the status alone.
1017
+
1018
+ So every QA run now writes a **purchase-proof coverage summary** onto the
1019
+ Assembly Report's `stages.qa`:
1020
+
1021
+ ```json
1022
+ "purchase_proof": {
1023
+ "declared_order_path_depth": "common",
1024
+ "declared_typed_card_depth": "common",
1025
+ "order_paths_executed": 3,
1026
+ "orders_created": 3,
1027
+ "orders_verified": 3,
1028
+ "all_orders_test_mode": true
1029
+ }
1030
+ ```
1031
+
1032
+ **Counts only, by design.** No order id, ref id, customer email, or checkout URL
1033
+ appears in it. This summary rides into the committed Assembly Report and the
1034
+ readback bundle, where the verdict's own order arrays are deliberately emptied
1035
+ (see the committed verdict sidecar above) — so the signal that a purchase
1036
+ happened has to be numbers, not the orders themselves. `all_orders_test_mode` is
1037
+ `null`, not `false`, when nothing ran: "no order left test mode" and "no order
1038
+ ran" are different facts.
1039
+
1040
+ Beside it, `stages.qa.evidence` carries the build the verdict judged and the
1041
+ outcome of the gates doctor's static scan can only approximate:
1042
+
1043
+ ```json
1044
+ "evidence": {
1045
+ "source_build_fingerprint": "sha256:…",
1046
+ "gates": { "placeholder_text_residue": { "status": "pass", "pages_checked": 2, "pages_failed": 0 } }
1047
+ }
1048
+ ```
1049
+
1050
+ A gate that did not run on that verdict is absent, never `pass`. Doctor reads
1051
+ `gates.placeholder_text_residue` back while `stages.assembly.build_fingerprint`
1052
+ still matches `source_build_fingerprint`: a recorded pass demotes the
1053
+ `template_contract.placeholder_text_residue` warning to a ready line and drops
1054
+ the matching `next` action; a rebuild or a failed gate brings the warning back
1055
+ (see `docs/template-family-contracts.md`). `evidence` is a QA-owned field, so
1056
+ the next QA record replaces it wholesale.
1057
+
1058
+ `next` compares the declared depth against what was exercised:
1059
+
1060
+ | Declared `order_path_depth` | `order_paths_executed` | `next` |
1061
+ |---|---|---|
1062
+ | absent, or `off`/`none` | anything | unaffected — intentional no-order diagnostics are preserved |
1063
+ | `common`, `full`, `tiers`, … | ≥ 1 | proceeds |
1064
+ | `common`, `full`, `tiers`, … | `0` | returns stage `qa`, not `done`, and says why in `picked_reason` |
1065
+ | `common`, `full`, `tiers`, … | **summary absent** | proceeds, with a non-required `purchase_proof_unknown` advisory |
1066
+
1067
+ That last row is load-bearing. Every report written before this summary existed
1068
+ has no `purchase_proof`, and an unknown must never retroactively un-finish a
1069
+ campaign that was already complete. Unknown is advisory; only an explicit zero
1070
+ holds the pipeline at `qa`.
1071
+
1072
+ If a no-order run is what you intend, declare it:
1073
+ `qa policy set --packet <packet> --order-path-depth off` (or
1074
+ `prepare-build`/`start ... --order-path-depth off` when the packet is first
1075
+ written). The setter accepts `off`, `common` or `full`, writes
1076
+ `qa.proof_policy.order_path_depth`, and — when the target already carries an
1077
+ assembly report — refreshes the report's `proof_policy` mirror in the same run.
1078
+ That is a deliberate, inspectable statement rather than a silent gap, and with
1079
+ `off` on both sides a `qa run --test-order off` pass reaches `next: done`.
1080
+
1081
+ Do not hand-edit the packet's depth: the assembly report mirrors
1082
+ `qa.proof_policy` from prepare-build, and when the two disagree `next` reads
1083
+ the depth as unknown and cannot reach `done`. Doctor warns
1084
+ (`qa.proof_policy.order_path_depth_drift`, advisory, never a blocker) and the
1085
+ `next` `purchase_proof_unknown` action becomes a runnable command, both naming
1086
+ the same fix: `qa policy set --packet <packet> --order-path-depth <packet
1087
+ value>`, which re-states the packet's value into the mirror.
1088
+
1089
+ ### Purchase data layer (`dl_purchase`)
1090
+
1091
+ Every typed-card path that places an order also records what the SDK's own
1092
+ data layer said about that order, on the order as `test_orders[].data_layer`,
1093
+ judged by one assertion per path,
1094
+ `analytics-correctness:data-layer-purchase:<path>`. The question is the one the
1095
+ current-SDK bump lane exists to prove and that used to live only in
1096
+ hand-authored evidence files (#325): **exactly one `dl_purchase` in
1097
+ `window.NextDataLayer` after the order, and it names the order the run just
1098
+ placed.**
1099
+
1100
+ Where the event fires matters. The SDK raises `dl_purchase` from
1101
+ `order:completed`, on the **first page opened with `?ref_id=`** that fetches the
1102
+ order back — the upsell page on a funnel that has one, the receipt only when
1103
+ nothing sits between checkout and receipt — and then remembers the transaction
1104
+ id per browser and drops the event on every later page of the same order (a
1105
+ reload, a new tab, the receipt after an upsell). So the reading is the whole
1106
+ post-checkout journey: the runner's data-layer hook (the same one the analytics
1107
+ leg uses) records every push on every document the path visits, and the count
1108
+ is taken across all of them, with a per-document breakdown in evidence. The
1109
+ runner waits for the event to arrive (bounded by `--analytics-settle`, default
1110
+ `5000` ms, and the order deadline), then a one-second grace so a second push has
1111
+ time to land before the count is taken. It needs no CampaignSpec analytics
1112
+ block: the SDK writes this array whether or not any provider is declared.
1113
+
1114
+ | `outcome` | What was read | Assertion |
1115
+ |---|---|---|
1116
+ | `pass` | one `dl_purchase`; its `ecommerce.transaction_id` is the placed order's number or ref id | `pass` |
1117
+ | `absent` | no `dl_purchase` on any page after the order | `fail` / blocker |
1118
+ | `duplicate` | more than one `dl_purchase` — on one page (double bootstrap) or across pages (the dedupe failed); a funnel that reports the purchase twice double-counts revenue, and is a FAIL the same as one with none (#302) | `fail` / blocker |
1119
+ | `mismatch` | one `dl_purchase`, but it names a different order, or none | `fail` / blocker |
1120
+ | `order_ref_unknown` | one `dl_purchase`, but the run recorded no order number or ref id to match it against | `manual_review` / warn |
1121
+ | `unmeasured` | the hook could not attach or mirror pushes out of the page — recorded with `measured: false` and null counts, never as a zero reading | `fail` / blocker |
1122
+
1123
+ The record carries `count`, `expected_order_refs`, `observed_transaction_ids`
1124
+ (in push order), `order_ref_match`, `outcome`, `ok` and `reason`; the raw probe
1125
+ (`event_counts` per event name, `documents` with each page's event and purchase
1126
+ counts, and each `dl_purchase`'s page, `transaction_id`, `value`, `currency`)
1127
+ sits under `evidence.data_layer` on the order and on the assertion. Like every
1128
+ order field it stays in the full verdict and is stripped from the committed
1129
+ sidecar.
1130
+
1131
+ Three things the count deliberately does not do. It reads
1132
+ `window.NextDataLayer` only — a GTM adapter legitimately re-pushes the same
1133
+ event to `window.dataLayer`, and that mirror is not a duplicate. It never counts
1134
+ `dl_upsell_purchase`, a different event that an accepted upsell legitimately
1135
+ adds. And it is not re-taken on a recovery pass: a fresh reading of a receipt
1136
+ the SDK has already reported would say `absent` for an order that reported
1137
+ correctly the first time. A failure here is its own blocker, not a
1138
+ `browser-test-order` failure: the order was created; what is wrong is what the
1139
+ funnel told analytics about it.
1140
+
1141
+ ### Step-ladder evidence
1142
+
1143
+ Every typed-card path executes as an ordered ladder of named, individually timed
1144
+ steps, and each step appends to the ladder the moment it finishes — a crash or
1145
+ timeout still leaves the ladder up to the point of failure. Ladder entries carry
1146
+ `step`, `status`, `started_at`, `duration_ms`, an optional human-readable
1147
+ `detail`, and, where the step has something structured to say, an `evidence`
1148
+ object. Evidence is resolved even when the step fails or times out, because the
1149
+ failing path is the one worth reading.
1150
+
1151
+ Four steps write structured evidence today.
1152
+
1153
+ **`entered_via_landing` — cart entry.** The first rung of the ladder, before
1154
+ `opened_checkout`. A checkout renders its customer form whether or not the SDK
1155
+ cart holds anything, so the runner cannot tell, from the checkout alone, a
1156
+ funnel that selects the package *on* the checkout (bundle cards on the checkout
1157
+ page) from one that filled the cart *upstream* and only displays it — the
1158
+ `shop-single-step` shape, where the landing page adds to the cart and hands
1159
+ off. Opening the checkout URL directly on the second shape used to run every
1160
+ fill step green and then sit in `order_submitted` for the full step budget,
1161
+ because the SDK never posts an order for an empty cart.
1162
+
1163
+ The step runs the **selector probe**: it loads the checkout once and reads it
1164
+ for a main-cart selection surface —
1165
+ `[data-next-bundle-selector]`, `[data-next-cart-selector]`, or a
1166
+ `[data-next-package-id]` card, **not** counting anything inside the rendered
1167
+ `[data-next-cart-summary]`, order-bump toggles, upsell-context selectors, or
1168
+ unrendered `<template>` content. That load is the checkout's only load before
1169
+ the entry page, and the run remembers the answer per checkout URL: a
1170
+ `tiers:*` plan that drives the same checkout once per tier probes it on the
1171
+ first path only, and every later path reads the stored answer
1172
+ (`selection_surface_probe: reused` in the step evidence, `loaded` on the path
1173
+ that ran the probe). A probe whose page-side read failed is tagged
1174
+ `selection_surface_probe: failed` with the error in
1175
+ `selection_surface_probe_error`; the path proceeds to the entry page as
1176
+ before, but the evidence says the read broke rather than that the checkout
1177
+ carries nothing, and the failure is never stored for later paths. When a
1178
+ surface is present the step is `skipped` with that reason and
1179
+ `opened_checkout` keeps the page the probe left on the checkout
1180
+ (`already on checkout from the selector probe; not re-opened`), opening the
1181
+ checkout itself only when no prior path ran the probe and left the page on
1182
+ the checkout; the rest of the ladder is unchanged — existing families run exactly as they
1183
+ did, with the checkout loaded once per path rather than twice. That matters
1184
+ because every checkout load boots the SDK and fires its page-view events into
1185
+ the same capture the analytics legs and the receipt capture read, so a second
1186
+ load of the same URL was counted as the campaign's own traffic. When no
1187
+ surface is present the runner resolves the funnel's entry page from the
1188
+ same topology the rest of the ladder uses (the page whose `expected_next_url`
1189
+ is the checkout, preferring a `select`/`landing`/`product` page and then the
1190
+ lowest `order`; failing that, the topology's first entry-like page before the
1191
+ checkout — never a receipt or an offer page), navigates there, waits for the
1192
+ SDK, and clicks the cart-entry control: an SDK add-to-cart control
1193
+ (`[data-next-action="add-to-cart"]`, the only attribute the SDK activates the
1194
+ feature on), or a link into the checkout URL carrying `?forcePackageId=`,
1195
+ which is what the certified `shop-single-step` landing renders. A control
1196
+ spelled any other way is not a cart entry, so a page that offers nothing else
1197
+ fails the step by name (`cart_entry_control_missing`) rather than clicking a
1198
+ control the SDK never wired and waiting out the navigation budget. A visible
1199
+ SDK control is preferred over a visible link, and a hidden control is used only
1200
+ when nothing is visible. `--select-package <ref>` is strict
1201
+ here as it is on checkout: the control must carry that package id (own
1202
+ attribute, nearest card, or the `forcePackageId` ref) or the step fails by
1203
+ name; an explicit quantity (`--select-package 1:2`) must match what the
1204
+ control adds (`data-next-quantity`, or the `ref:qty` of the link) and is never
1205
+ downgraded; and only one ref can be selected before the SDK navigates away. The
1206
+ runner then **waits for the page to reach the checkout URL** — the SDK owns
1207
+ that navigation through `data-next-url`; the runner never opens the checkout
1208
+ itself after the click, because a fresh navigation is what would throw the
1209
+ cart away. `opened_checkout` then records the arrival instead of re-opening.
1210
+
1211
+ Evidence: `landing_url`, `landing_page_id`, `landing_page_type`,
1212
+ `landing_resolution` (`routes_into_checkout`, `entry_page_fallback`,
1213
+ `first_page_fallback`), `control_text`, `control_kind` (`add_to_cart` or
1214
+ `checkout_link`), `package_id`, `sdk_ready`, `arrived_url`, the
1215
+ `checkout_selection_surface` probe result, `selection_surface_probe`
1216
+ (`loaded`, `reused`, or `failed`), and `selection_surface_probe_error` when it
1217
+ failed. The failure codes are
1218
+ `cart_entry_unresolved` (no selection surface on checkout and no entry page
1219
+ resolves from the topology), `cart_entry_control_missing` (the entry page
1220
+ renders no control, or none carrying the requested ref), and
1221
+ `cart_entry_no_navigation` (the click did not reach the checkout URL). Each
1222
+ fails the path inside the step budget with the code as the first word of the
1223
+ error, never as a step timeout.
1224
+
1225
+ Which page a funnel enters the cart from is still inferred from topology and
1226
+ the rendered checkout. Recording it authoritatively on the spec is the open
1227
+ design half of campaigns-os#206; the `landing_resolution` evidence exists so a
1228
+ reader can see which inference the runner made.
1229
+
1230
+ **`customer_fields_filled` — customer/address-field trace.** Each field is
1231
+ recorded before its action runs and updated after, as
1232
+ `{ field, action, status, optional, duration_ms }`. Statuses are `ok`,
1233
+ `unusable` (an optional field that reported visible but would not accept input —
1234
+ best-effort, not a failure), `failed`, and `pending`. `pending` is the useful
1235
+ one: a step that hangs mid-field leaves that field pending, so the verdict names
1236
+ the field instead of reporting an anonymous step timeout. The summary lifts the
1237
+ first failed-or-pending field to `blocking_field` / `blocking_status`.
1238
+
1239
+ Coverage is `customer_and_address_fields` and the name is literal: this trace
1240
+ covers the customer and shipping/billing fields reached through
1241
+ `[data-next-checkout-field]`. It does **not** cover payment entry — the card
1242
+ number and CVV are typed into cross-origin hosted iframes that no page-side
1243
+ trace can observe.
1244
+
1245
+ Required field actions are bounded by the step budget, capped at Playwright's
1246
+ own 30s default, so a caller-supplied budget only ever tightens the ceiling. A
1247
+ stuck required field fails as that field rather than as an anonymous step
1248
+ timeout; a slow-but-working funnel waits no longer than it did before.
1249
+
1250
+ **`cart_created` — cart-API observation.** The step reports what the cart API
1251
+ actually returned: the most recent `POST /api/v1/carts/` response's `status`, an
1252
+ `ok` flag, `line_count` when the response body exposes lines, `response_count`,
1253
+ and the query-redacted `url`. Matching is anchored like the order-create
1254
+ patterns, so a querystring still matches while `/api/v1/carts/calculate/`
1255
+ repricing calls do not — a repricing call is not evidence that a cart was
1256
+ created. A campaign whose checkout posts the order directly, with no cart call
1257
+ at all, still records the step as `skipped` with that reason; a create that
1258
+ responds non-2xx is reported as `ok: false` rather than hidden. A response body
1259
+ whose line shape is unreadable omits `line_count` rather than reporting zero.
1260
+
1261
+ **`order_submitted` — empty-cart guard.** Immediately before the creation
1262
+ reservation and the submit click, the runner reads the cart the page holds:
1263
+ the SDK's public API first (`window.next.getCartCount()`, the store's own
1264
+ `totalQuantity`, installed on every SDK page), the debugger's cart store
1265
+ second (`window.nextDebug.stores.cart`, present with `?debugger=true`, and
1266
+ the only public place the line items and package ids are readable), and the
1267
+ observed cart-API create response third. The enriched line list on
1268
+ `getCartData()` is never read, for the reason the cart-state verification
1269
+ section above gives. A cart that reads as zero items fails
1270
+ the step with `cart_empty_before_submit` — no submit click is made and no
1271
+ creation slot is reserved, so the failure classifies as `not_created` under the
1272
+ #316 budget semantics and keeps its bounded re-run. The step's evidence carries
1273
+ `cart_before_submit` (`empty`, `source`, `count`, `line_count`,
1274
+ `package_ids`) on success and failure alike. A cart that cannot be read at all
1275
+ (`unreadable: true`, no SDK global and no cart call observed) is **not** treated
1276
+ as empty: the runner has no proof either way, the submit proceeds, and the
1277
+ platform decides. The read waits briefly for the SDK global before concluding
1278
+ it is absent, and the cart-API fallback only considers responses captured
1279
+ after the checkout was reached — a cart call the entry page made before the
1280
+ hand-off says nothing about the checkout's cart. There is no flag to skip the
1281
+ guard.
1282
+
1283
+ ### Package/bundle card selection and coupons
1284
+
1285
+ Two flags target funnels the default-tier drive cannot prove:
1286
+
1287
+ - `--select-package <ref[:qty],...>` — **strict** package/bundle card selection.
1288
+ Each ref is matched against rendered selector/bundle cards
1289
+ (`[data-next-package-id]`, `[data-next-bundle-card][data-next-bundle-id]`) and
1290
+ clicked. An explicit quantity requires one unambiguous rendered card whose
1291
+ `data-next-bundle-items` composition contains exactly that package and
1292
+ quantity; package identity alone is not enough. The card must then expose a
1293
+ selected-state marker (`data-next-selected="true"` / `.next-selected`) after
1294
+ the click. A missing card, ambiguous composition, wrong quantity, or
1295
+ unverifiable/refused selection **fails the `selected_bundle` step** instead
1296
+ of silently driving the pre-selected default tier. A bundle ref remains an
1297
+ authoritative identity when exactly one rendered card declares it. Use this
1298
+ flag to traverse non-default tiers of a multi-tier selector. `--cart` remains
1299
+ the best-effort variant.
1300
+ - `--apply-coupon <code>` — types the code into the rendered coupon/promo input
1301
+ (the SDK's `[data-next-checkout-field="coupon"]` or
1302
+ `input[data-next-coupon="input"]`, then hand-rolled `coupon`/`voucher`/`promo`
1303
+ inputs by name or placeholder, revealing a collapsed "Have a coupon?"
1304
+ disclosure when needed) and clicks the apply control before card entry, as a
1305
+ new `coupon_applied` ladder step. The apply control is the SDK's own
1306
+ `[data-next-coupon="apply"]` when the page renders one; otherwise a visible
1307
+ "Apply" control inside the form, else Enter in the input. No other
1308
+ `data-next-*` spelling is a coupon control the SDK wires, so none is looked
1309
+ for. Funnels
1310
+ with **no shopper-typable coupon surface** (the code is applied by page JS,
1311
+ e.g. an exit-intent overlay calling `window.next.applyCoupon("CODE")`) fall
1312
+ back to the SDK `applyCoupon` API — the step detail records that the
1313
+ shopper-facing trigger was not exercised, so verify that trigger separately.
1314
+ The apply mechanics never pass the proof on their own: the path passes only
1315
+ on **persisted-order read-back evidence**, checked in this order — the
1316
+ requested voucher code itemized on the order (authoritative); a positive
1317
+ discount total when no voucher entries exist (weak); or, on platforms that
1318
+ **net the voucher into line prices and itemize nothing** (no voucher keys,
1319
+ empty `discounts`, zero `total_discounts`), a line-price delta: the charged
1320
+ line total must sit below the campaign package list total captured from the
1321
+ campaign API during the run (weak, `basis: "line_price_delta"`; charged ==
1322
+ list fails as "coupon did not apply"). A mismatched voucher or no discount
1323
+ evidence on any basis fails the path.
1324
+
1325
+ ### Spec-driven tier and coupon iteration (`--test-order tiers`)
1326
+
1327
+ `--select-package` and `--apply-coupon` are operator-passed and apply globally
1328
+ to every path in a run, so exercising a multi-tier selector one flag at a time
1329
+ takes one run per tier. **`--test-order tiers`** derives the order matrix from
1330
+ the CampaignSpec instead:
1331
+
1332
+ - one strict-selection **checkout baseline per selector tier** the spec declares
1333
+ in the checkout page's `packages` (refs read from `ref_id`/`package_id`/`id`,
1334
+ deduplicated by ref and purchase quantity, in declaration order) — each tier
1335
+ goes through the same strict `--select-package` machinery, so a tier whose
1336
+ card is missing, ambiguous, quantity-mismatched, or refuses selection fails
1337
+ its path. Repeated declarations of the same ref at distinct quantities are
1338
+ purchase multipliers (`ref` and `ref:2`). A uniquely referenced catalog
1339
+ package with its own `qty: 3` composition is still bought once (`ref`), not
1340
+ multiplied by three. **Order-bump rows — `packages[]` entries marked
1341
+ `is_upsell: true` — are add-ons offered beside the selected tier, not tiers**:
1342
+ they never become a plan (a three-tier checkout with one bump plans three
1343
+ tiers, so `tiers:common` on a two-upsell funnel is 12 orders, not 16), and
1344
+ the runner prints a `[qa:test-order]` line naming the bump ref(s) it left
1345
+ out. Bump coverage comes from `--cart`;
1346
+ - plus one **checkout order per declared coupon code** — checkout
1347
+ `exit_intent.offer_code` and `promo_code_input.offer_code`, counted only when
1348
+ the surface has `enabled: true` (the same rule build/doctor use for offer
1349
+ surfaces), deduplicated case-insensitively across the two surfaces. Coupon
1350
+ orders run on the default tier selection and are proven by the same
1351
+ persisted-order read-back ladder as `--apply-coupon` (voucher itemization,
1352
+ then discount-total, then the `line_price_delta` weak-evidence basis for
1353
+ platforms that net vouchers into line prices; SDK `applyCoupon` fallback when
1354
+ no shopper-typable input exists).
1355
+
1356
+ Two variants cross tiers with path shapes in a single run:
1357
+
1358
+ - `tiers:common` — every declared tier × that checkout's common path shapes
1359
+ (checkout/accept/decline plus a deduplicated shortest real receipt path);
1360
+ - `tiers:full` — every declared tier × that checkout's full set of actual
1361
+ terminal paths. This is single-run tier×path coverage; expect the expanded
1362
+ count to exceed the default `--max-test-orders` and raise the cap deliberately.
1363
+
1364
+ The persisted order read-back must reconcile the selected package's unit
1365
+ composition multiplied by the requested purchase quantity. For example,
1366
+ selecting `1:2` for a one-unit package proves only when the persisted line has
1367
+ quantity two. A line with the right SKU but the wrong quantity fails; duplicate
1368
+ same-SKU package candidates remain ambiguous unless rendered or requested
1369
+ package identity resolves them. Checkout total parity reads the standard
1370
+ `data-next-display="cart.total"` surface and the maintained Demeter
1371
+ `[data-next-cart-summary] .order-totals__value--total` surface. If neither is
1372
+ readable, total parity is explicitly skipped as unavailable rather than passed.
1373
+
1374
+ Coupon plans stay single checkout orders in every variant: coupon proof is
1375
+ persisted-order read-back and does not need upsell traversal. Each planned
1376
+ order is labeled in assertions and evidence as `checkout@tier:<ref>`,
1377
+ `accept@tier:<ref>`, `checkout@coupon:<code>`, and the verdict records the
1378
+ plan (tier ref or coupon code plus its declaring surface) on the order.
1379
+
1380
+ `--select-package <ref[:qty],...>` **narrows** a tiers run to the listed
1381
+ declared tiers, matched by exact identity (`1` or `1:1` is ref 1 at purchase
1382
+ quantity one; `1:2` is the two-unit multiplier), so `--test-order tiers:common
1383
+ --select-package 1:2,1:3` proves two of three tiers without the full flood.
1384
+ Coupon plans are not tiers and are planned regardless. Every listed identity
1385
+ must be a declared tier: any that is not is refused by name, listing the
1386
+ declared tiers, so a partly declared list never runs the matched tiers and
1387
+ silently skips the rest.
1388
+ `tiers` is incompatible with explicit `--apply-coupon` (the mode derives
1389
+ coupons from the spec; combining would be ambiguous), and it errors when the
1390
+ spec declares neither selector tiers nor an enabled offer code — use
1391
+ `common`/`full` or the explicit flags there. Because tiers come from the
1392
+ CampaignSpec, `tiers` needs a packet/spec-driven run; non-packet `--site`
1393
+ runs have no declared tiers to iterate.
1394
+
1395
+ **Multi-funnel specs are covered in one run**: every funnel's checkout page
1396
+ contributes plans, and each plan is driven against the checkout page that
1397
+ declares its tier or coupon (strict-selecting a ref on a checkout that does
1398
+ not render it would fail for the wrong reason). Plans from the primary (first)
1399
+ checkout keep bare ids; other funnels' plans are qualified by page id —
1400
+ `checkout@tier:8#checkout-b` — so the same ref or code declared on two
1401
+ checkouts cannot collide, and `tiers:common`/`tiers:full` cross each funnel's
1402
+ tiers with **that funnel's own isolated topology graph and terminals**. A non-primary checkout that
1403
+ declares tiers/coupons but has no resolvable URL cannot be driven; the runner
1404
+ prints a `[qa:test-order]` warning naming it instead of silently dropping the
1405
+ declarations.
1406
+
1407
+ ```bash
1408
+ npm run campaigns-os -- qa run \
1409
+ --packet campaign-runtime.build.json \
1410
+ --base-url https://preview.example.com/campaign/ \
1411
+ --browser \
1412
+ --test-order tiers
1413
+
1414
+ # exhaustive tier×path proof, cap raised deliberately
1415
+ npm run campaigns-os -- qa run \
1416
+ --packet campaign-runtime.build.json \
1417
+ --base-url https://preview.example.com/campaign/ \
1418
+ --browser \
1419
+ --test-order tiers:full --max-test-orders 15
1420
+ ```
1421
+
1422
+ `--max-test-orders` (default `6`) is an **accidental-flood guard, not a permission
1423
+ gate**. A single checkout's `common` sample always stays under it, though tier
1424
+ expansion can exceed it. If `full` expands past the cap, the command stops before
1425
+ browser launch, prints the planned count, lists the planned paths (up to 40 ids;
1426
+ past that the remainder is counted, never cut silently, and `--select-package
1427
+ <ref[:qty]>` lists one tier's paths), and names the exact `--max-test-orders <count>` raise. For example, a linear three-offer graph has
1428
+ eight terminal paths plus the checkout baseline, so it requires
1429
+ `--max-test-orders 9`. No approval step is involved.
1430
+
1431
+ `--max-test-orders` bounds **planned paths**, which is not the same as real
1432
+ purchases. `--max-order-creations` bounds **actual order creations**, defaults to
1433
+ the planned path count, and is reserved immediately before each submit click —
1434
+ before the purchase, never reconciled after it. An exhausted budget stops that
1435
+ path with its own assertion text and its own `order_creation_budget` evidence, so
1436
+ a safety stop the runner chose can never be read as a broken checkout. Nothing
1437
+ was submitted for that path, so it is recorded as `manual_review` at `warn`
1438
+ severity — the same "a human decides this one" vocabulary a hosted-checkout
1439
+ redirect uses — and never as a blocker-severity `fail`, which belongs to a
1440
+ checkout the runner watched fail. A run that spends its budget therefore
1441
+ finalizes `ready_with_exceptions`, not `blocked`: the unexercised path is listed
1442
+ in `exceptions[]` so it can never pass for a clean `ready`, but no supervisor is
1443
+ sent after a checkout repair that has nothing to repair. The value
1444
+ is validated on the budget itself, which every browser path builds — `qa run`
1445
+ and `qa parity` alike: a non-numeric, fractional, negative, or zero
1446
+ `--max-order-creations` is an error naming the flag, never a silent fall back to
1447
+ the default budget.
1448
+
1449
+ ### What happens when a path fails
1450
+
1451
+ A failed path is not one thing, and the runner does not treat it as one. Before
1452
+ deciding what to do next, it classifies what the attempt did to the store:
1453
+
1454
+ | Classification | What it means | What the runner does |
1455
+ |---|---|---|
1456
+ | `not_created` | The path failed before the checkout was submitted (including the runner's own named refusals: `cart_entry_unresolved`, `cart_entry_control_missing`, `cart_entry_no_navigation`, `cart_empty_before_submit`), or the platform rejected every order create it saw. Nothing reached the store. | Re-runs the path once, if the creation budget has a slot no still-unrun planned path needs. This is the bounded retry for a transient miss; the re-run decides the assertion. |
1457
+ | `created` | An order exists and was read back — the failure happened after the purchase (most often a receipt that did not render its line items). | Runs a **read-only recovery pass**: reloads the receipt the order already produced, re-reads the persisted order, and re-checks the buyer-visible receipt surface and the voucher read-back. It clicks nothing, applies nothing, and submits nothing. |
1458
+ | `ambiguous` | The submit may have created an order this runner cannot see: a ref id with an unusable read-back, a lost create response, a network-failed create, or a 4xx that follows an earlier 2xx on the same endpoint. | Stops. It never resubmits, and the assertion names the check an operator should run — look for an existing order against the run's QA email or the observed ref id. |
1459
+
1460
+ The classification fails closed: anything not provably not-created is ambiguous,
1461
+ and ambiguous is never resubmitted. A `manual_review` (a hosted-checkout
1462
+ redirect) is still never re-run, and it charges the creation budget, because the
1463
+ platform may have created an order behind the redirect.
1464
+
1465
+ Whether an order create succeeded is decided from the **whole** event log,
1466
+ counted once while the runner still holds it. The log that travels in the
1467
+ evidence payload keeps only the last 20 entries per stream, and on a multi-offer
1468
+ path the upsell and cart traffic that follows a successful create pushes that
1469
+ create out of that window. A classifier reading the truncated copy would see a
1470
+ bare rejection, call the path `not_created`, and submit again against a store
1471
+ that already holds the order.
1472
+
1473
+ A re-run is bounded twice over: once per path per run, and never with budget a
1474
+ planned path still needs. Under the default budget — one creation per planned
1475
+ path — a path whose submit was **rejected** has already spent its own slot, so it
1476
+ is not re-run and its assertion records why under
1477
+ `evidence.order_creation.rerun_skipped`. Raise `--max-order-creations` to buy
1478
+ re-runs for those paths. A re-run that stops on the budget never becomes the
1479
+ deciding result: it proved nothing, so the first attempt's real failure stands.
1480
+
1481
+ Recovery may only clear a failure on evidence it actually re-read. If the
1482
+ receipt reload's persisted-order read-back fails or never happens, the pass stops
1483
+ honestly: the read-back failure is itself a remaining failure, and the receipt
1484
+ rendering and voucher checks are recorded as not re-assessed rather than
1485
+ re-decided against the original attempt's numbers. A `tiers` run's coupon lives
1486
+ on its plan, not on the run-level flags, and recovery re-checks it from there.
1487
+
1488
+ A pass that only came back after recovery is never presented as a first-attempt
1489
+ pass. The assertion carries `evidence.order_creation` (classification, reason,
1490
+ action, and two separate counts) and, where a recovery
1491
+ pass ran, `evidence.recovery` with the original failure, the checks that were
1492
+ re-run, and whether it cleared. An upsell-action failure cannot be cleared by
1493
+ recovery — re-clicking the offer would mutate the order under inspection — so it
1494
+ is reported as having survived the pass.
1495
+
1496
+ The two counts answer two different questions, and neither is a substitute for
1497
+ the other. `submissions_reserved` is what the run **spent**: the platform-side creation
1498
+ slots charged to this path. Most are reserved immediately before a submit click;
1499
+ a hosted-checkout `manual_review` charges one for a redirect where no submit
1500
+ click happens at all. A slot stands whether or not the create that followed
1501
+ succeeded. `orders_confirmed_created` is what the platform was
1502
+ **observed to accept** on that path, counted from the whole event log. They agree
1503
+ on the ordinary path and diverge exactly where it matters — a spent slot with no
1504
+ confirmed order is the ambiguous case, a path to check against the store rather
1505
+ than an order to reconcile.
1506
+
1507
+ The default card is the Discover test card `6011 1111 1111 1117`, CVV `123`,
1508
+ expiration `12/2030` (success path; `6011 0009 9013 9424` exercises 3DS). Override
1509
+ with `--test-card`, `--test-cvv`, `--test-exp-month`, and `--test-exp-year`.
1510
+
1511
+ ### Test customer email
1512
+
1513
+ All test orders should reuse **one** customer, because the Customer/user record
1514
+ is not deletable — minting a fresh email per run litters the customer list. Set
1515
+ the address with `--test-email <email>` or `CAMPAIGNS_OS_QA_TEST_EMAIL`. Prefer a
1516
+ **real, monitored inbox** so the ESP delivers order/receipt notifications instead
1517
+ of accumulating bounces to an unroutable address (this is why internal runs use a
1518
+ shared real inbox rather than a synthetic one). When neither is set, the runner
1519
+ falls back to a single stable synthetic address — still one reused customer, but
1520
+ not deliverable.
1521
+
1522
+ The browser driver intentionally behaves like a user:
1523
+
1524
+ - package selection uses rendered `[data-next-package-id]` controls when
1525
+ `--cart <package-ref:qty,...>` is supplied (best-effort) or
1526
+ `--select-package <ref[:qty],...>` (strict — misses fail the path)
1527
+ - coupon codes from `--apply-coupon` are typed into the rendered promo input and
1528
+ proven against the persisted-order voucher read-back
1529
+ - checkout is advanced through the visible cart/checkout button
1530
+ - address autocomplete is settled or closed before submit
1531
+ - Spreedly card and CVV iframes are filled with sequential keystrokes
1532
+ - the real submit button is clicked without fabricating SDK state
1533
+
1534
+ The intended QA order matrix is:
1535
+
1536
+ 1. Checkout path with the target bundle/cart selected and typed card accepted.
1537
+ 2. Upsell-decline path by clicking the rendered SDK decline/skip control.
1538
+ 3. Upsell-accept path by clicking the rendered SDK accept/add control.
1539
+ 4. Receipt/order verification from the resulting `ref_id`, including line items,
1540
+ selected packages, quantities, shipping method, vouchers/promo codes, discounts,
1541
+ and upsell result.
1542
+
1543
+ For multi-market campaigns, add at least one non-default currency/country path
1544
+ to the QA pass. Verify currency display, shipping method names and prices,
1545
+ available payment methods, and market-specific copy such as delivery promises,
1546
+ warehouse origin, carrier names, free-shipping claims, and manufacturing claims.
1547
+ Doctor also warns on two adjacent copy risks before QA: hardcoded `$XX.XX`
1548
+ amounts outside SDK-bound display regions for multi-currency/non-USD campaigns,
1549
+ and hardcoded phone numbers that differ from CampaignSpec `campaign.store_phone`.
1550
+ If a static claim is intentionally preserved, wrap it in an element with
1551
+ `data-skip-market-lint="true"` and record why in the assembly report.
1552
+
1553
+ Test orders themselves need no allowlist or approval. A separate concern is the
1554
+ **SDK origin allowlist**: the Campaign Cart SDK must be allowed to load on the
1555
+ tested origin for the campaign API key, or runtime checks (and the live page
1556
+ itself) may not initialize. Localhost on any port is globally available as a
1557
+ Campaigns App **Development domain**; SDK calls are allowed there and Campaigns
1558
+ analytics events are suppressed. Non-localhost preview/production origins still
1559
+ need SDK origin allowlist confirmation. `qa policy set` records that origin
1560
+ confirmation in the Build Packet:
1561
+
1562
+ ```bash
1563
+ npm run campaigns-os -- qa policy set \
1564
+ --packet campaign-runtime.build.json \
1565
+ --allowed-domains-confirmed true
1566
+ ```
1567
+
1568
+ There is no permission flag for test orders — they run from `--test-order
1569
+ <mode>` alone. The former `--test-orders-allowed` /
1570
+ `--sandbox-test-card-confirmed` flags and the `qa.test_orders_allowed` /
1571
+ `qa.sandbox_test_card_confirmed` packet fields they set were removed in
1572
+ supported surface 1.28.0: nothing had read their values since the gate itself
1573
+ was retired, so they only ever recorded an intention no command honoured.
1574
+ `qa policy set` now refuses the two flags by name; doctor warns
1575
+ (`qa.removed_policy_fields`) on a packet that still carries a field and asks
1576
+ for it to be deleted. The remaining `qa policy set` flags are
1577
+ `--allowed-domains-confirmed`, `--deploy-target`, `--preview-url` and
1578
+ `--production-url`.
1579
+
1580
+ For QA against a locally served build, set `--deploy-target local-serve` and
1581
+ record the served localhost URL as `--preview-url`; see the deploy target
1582
+ table in [build-packet.md](./build-packet.md#deploy-target) and
1583
+ [Local proof mode](#local-proof-mode-deploytarget-local-serve) above for the
1584
+ development build, the parity check, and the order they run in.
1585
+
1586
+ ## Launch Readiness Note
1587
+
1588
+ Campaigns OS can prove the campaign build, SDK wiring, browser behavior, and
1589
+ typed-card order paths. It does not prove the merchant is ready for real
1590
+ shoppers. Before launch, confirm the production storefront URL, live payment
1591
+ methods, shipping markets, legal/support URLs, analytics expectations, and
1592
+ merchant-side configuration. Treat these as real-shopper readiness items, not
1593
+ Campaigns OS build blockers.
1594
+
1595
+ The accepted-upsell path passes only after the browser clicks the rendered SDK
1596
+ accept/add control, observes the order upsell API mutation, and the final order
1597
+ evidence contains the selected upsell package. A pre-purchase bump line marked
1598
+ `is_upsell` is not enough to satisfy accepted-upsell proof.
1599
+
1600
+ For launch-grade proof on funnels with a checkout bump and post-checkout offers,
1601
+ use the declared topology instead of a single happy path:
1602
+
1603
+ 1. Checkout-only with the base cart.
1604
+ 2. Checkout-only with the base cart plus bump when the bump is in scope.
1605
+ 3. Base cart through the checkout/first-action sample plus the shortest real
1606
+ receipt path (`--test-order common` covers up to four deduplicated shapes).
1607
+ 4. Base plus bump cart through the same sample matrix when bump behavior is
1608
+ launch-relevant.
1609
+ 5. Use `full` when you want every actual terminal path, raising the flood cap to
1610
+ the exact planned count when necessary.
1611
+
1612
+ Record order numbers, `ref_id` values, and expected line-item shapes in the
1613
+ handoff. If the browser console shows an SDK module-load error but the SDK
1614
+ fallback loads and checkout/order proof passes, keep it as platform warning
1615
+ evidence for the Campaign Cart owner instead of patching campaign source around
1616
+ it.
1617
+
1618
+ The older direct backend mode is available only as
1619
+ `--legacy-api-test-order <accept|decline|both>`. It is diagnostic behavior, not
1620
+ canonical launch proof, because it bypasses the deployed campaign page and the
1621
+ SDK checkout/upsell surfaces.
1622
+
1623
+ ## Non-packet QA against a built `_site/` (no Build Packet)
1624
+
1625
+ A `campaign-build`'d page-kit campaign produces a built `_site/` but no full
1626
+ Build Packet. Doctor and QA can still run against it: scope (pages + funnel
1627
+ types) is resolved from the built output, and the residue / placeholder-text /
1628
+ demo-asset gates run against the chosen family's brand contract.
1629
+
1630
+ ```bash
1631
+ # Doctor a built campaign with no packet; optionally auto-emit a minimal packet.
1632
+ npm run campaigns-os -- doctor --built ../my-campaign-repo --family arjuna --emit-packet
1633
+
1634
+ # QA a built, served campaign with no packet/spec.
1635
+ npm run campaigns-os -- qa run --site ../my-campaign-repo --base-url http://localhost:8080 --family arjuna --browser
1636
+ ```
1637
+
1638
+ `--family` is required (the residue gates need the family's brand contract).
1639
+ `--slug` selects the campaign when `_site/` holds more than one. With no theme
1640
+ artifacts the theme gate resolves to `not_applicable` (non-blocking), so the
1641
+ placeholder-text blocker and the other residue gates still run. The emitted
1642
+ minimal packet is marked `_synthesized` — it points doctor/QA at the built
1643
+ output and family, and is not a substitute for a real Build Packet.
1644
+
1645
+ **Trade-off — non-packet QA is narrower than packet-driven QA.** It runs the
1646
+ built-output gates (residue, placeholder text, demo-asset, pricing-CSS, brand
1647
+ contract) but **skips the CampaignSpec/source-HTML-driven checks** a packet
1648
+ enables: page-coverage and route parity against the spec, SDK meta-tag
1649
+ expectations, and commerce-ref validation. A doctor-clean non-packet run means
1650
+ "the built output carries no template residue", **not** "the commerce wiring
1651
+ matches a spec". Treat it as a residue/visual gate, not equivalent to a
1652
+ packet-driven QA pass.
1653
+
1654
+ ### Per-page credential declarations
1655
+
1656
+ Canonical `qa run` emits `page-binding:<page_id>` in the existing `api-metadata`
1657
+ family, with `campaigns-os-page-binding/v0` evidence (typed in the verdict schema).
1658
+ `match` means the statically declared credential equals the expected credential;
1659
+ `mismatch` is a blocker. `unknown` requires manual review. All three carry
1660
+ `identity: not_verified`: credential equality never proves a unique Campaign App
1661
+ ID, and no App ID is inferred from `campaignId` or `next-campaign-id`.
1662
+
1663
+ Expected data reuses the commercial QA resolver (packet, then spec, then an
1664
+ explicit supported environment source). Conflicting authored values are unknown.
1665
+ The SDK loads `window.nextConfig.apiKey` before `next-api-key` at boot, so meta
1666
+ wins at runtime; this check deliberately reports differing declarations as a
1667
+ conflict rather than certifying one. It does not observe SDK execution.
1668
+
1669
+ The bounded HTML loader is reused. HTML is parsed without execution; JavaScript
1670
+ is parsed with Acorn, accepting only unconditional literal `window.nextConfig`
1671
+ object or `.apiKey` assignments. Getters, spreads, computed values, branches,
1672
+ other executable statements, modules, async/nomodule scripts, event handlers and a base element require
1673
+ review. Nested Google Maps/payment keys and inert HTML do not count as campaign
1674
+ credentials. This small static grammar deliberately leaves many real pages
1675
+ unknown; a literal inside arbitrary code is not proof of effective configuration.
1676
+
1677
+ External executable scripts other than the recognized jsDelivr Campaign Cart
1678
+ loader/index are inspected only on the page's origin. Each page admits at most
1679
+ 6 such references; each run fetches at most 24 distinct URLs (deduplicated),
1680
+ 256 KiB per response and 6 MiB aggregate, 5 seconds per request including body
1681
+ read (at most 30 seconds of sequential config requests per page). Redirects,
1682
+ credential-bearing URL authority, cross-origin URLs, missing/unreadable scripts,
1683
+ and limits all produce unknown. Config requests never forward cookies or auth.
1684
+ Page redirects within the origin resolve relative config paths against the final URL; a changed origin is unknown. There are no recursive imports or API lookups. Keys are transient comparison
1685
+ inputs: no raw value, masked fragment, digest, config URL, or exception text is
1686
+ included in this evidence. Credential meta hints are excluded from ordinary
1687
+ meta assertions to avoid duplicating their values into the verdict.
1688
+
1689
+ Older verdicts lacking this assertion were not checked. Consumers must retain
1690
+ run/time/spec-hash context and segregate server-stamped untrusted submissions;
1691
+ a trusted submission attests the runner, not execution or resource identity.