@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.
- package/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- 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.
|