@nextcommerce/campaigns-os 1.61.0 → 1.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +3 -0
  22. package/campaign-spec/dist/rules/index.js +9 -0
  23. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  24. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  25. package/campaign-spec/dist/types.d.ts +59 -9
  26. package/compatibility.json +1 -1
  27. package/contracts/commerce-surface-catalog.json +59 -53
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
  44. package/contracts/release-ledger.json +1260 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
  46. package/contracts/supported-surface.json +8 -8
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/build-packet.md +430 -37
  49. package/docs/campaign-standardization-report.md +4 -4
  50. package/docs/campaigns-os-build-flow.md +54 -3
  51. package/docs/campaignspec-authoring-examples.md +59 -4
  52. package/docs/design-source-package.md +18 -6
  53. package/docs/effects.md +17 -9
  54. package/docs/local-setup.md +1 -1
  55. package/docs/orientation-contract-reference.md +1 -1
  56. package/docs/polish-evidence.md +2 -0
  57. package/docs/progress-snapshots.md +7 -4
  58. package/docs/qa-and-test-orders.md +147 -1
  59. package/docs/readback.md +40 -5
  60. package/docs/runtime-readiness.md +7 -3
  61. package/docs/sdk-storage-compatibility.md +1 -1
  62. package/docs/skills-revision.md +10 -10
  63. package/package.json +1 -1
  64. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  65. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  66. package/schemas/campaign-spec.v4.schema.json +34 -3
  67. package/schemas/campaign-spec.v5.schema.json +34 -3
  68. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  69. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  70. package/skills/campaign-readback-classification/SKILL.md +3 -3
  71. package/skills/campaign-run-evidence/SKILL.md +3 -3
  72. package/skills/contribution-intake/SKILL.md +3 -3
  73. package/skills/next-campaigns-build/SKILL.md +4 -3
  74. package/skills/next-campaigns-os/SKILL.md +6 -4
  75. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  76. package/skills/next-campaigns-polish/SKILL.md +5 -4
  77. package/skills/next-campaigns-qa/SKILL.md +7 -4
  78. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  79. package/skills.json +11 -11
  80. package/src/brand-theme.mjs +2 -1
  81. package/src/build-brief.mjs +4 -1
  82. package/src/campaign-workspace.mjs +288 -15
  83. package/src/change-scope.mjs +284 -0
  84. package/src/cli.mjs +308 -86
  85. package/src/commercial-journey.mjs +16 -3
  86. package/src/commercial-parity.mjs +15 -7
  87. package/src/content-residue.mjs +4 -2
  88. package/src/design-source-package.mjs +36 -0
  89. package/src/design-source-publication.mjs +9 -6
  90. package/src/directory-lock.mjs +73 -19
  91. package/src/doctor/checks.mjs +79 -7
  92. package/src/doctor/ci.mjs +75 -10
  93. package/src/doctor/inspect.mjs +23 -6
  94. package/src/doctor/next-step.mjs +7 -0
  95. package/src/doctor-sidecar.mjs +3 -2
  96. package/src/evidence-debt.mjs +47 -7
  97. package/src/finding-cause.mjs +12 -11
  98. package/src/input-refresh.mjs +339 -32
  99. package/src/invocation.mjs +7 -1
  100. package/src/live-campaign-refs.mjs +7 -2
  101. package/src/local-preview-policy.mjs +35 -2
  102. package/src/page-kit-analytics-vendors.mjs +132 -0
  103. package/src/polish-browser.mjs +3 -1
  104. package/src/polish-readability.mjs +221 -9
  105. package/src/polish-visual.mjs +9 -2
  106. package/src/progress-node.mjs +3 -2
  107. package/src/qa-analytics-parity.mjs +17 -1
  108. package/src/qa-browser.mjs +40 -4
  109. package/src/qa-commercial-parity.mjs +50 -3
  110. package/src/qa-node.mjs +196 -16
  111. package/src/qa-publish.mjs +9 -8
  112. package/src/qa-sidecar.mjs +16 -4
  113. package/src/qa-vendor-beacons.mjs +232 -0
  114. package/src/qa-vendor-endpoints.mjs +306 -0
  115. package/src/qc-accept.mjs +29 -1
  116. package/src/qc-check-registry.mjs +5 -1
  117. package/src/qc-results.mjs +73 -26
  118. package/src/readback.mjs +58 -10
  119. package/src/route-identity.mjs +10 -0
  120. package/src/run-record.mjs +18 -10
  121. package/src/runtime-state-ignore.mjs +6 -2
  122. package/src/sdk-storage-compatibility.mjs +21 -2
  123. package/src/source-html-intake.mjs +15 -1
  124. package/src/spec-derive-campaign.mjs +216 -0
  125. package/src/spec-derive.mjs +473 -53
  126. package/src/spec-identity.mjs +14 -0
  127. package/src/stage-ledger.mjs +40 -1
  128. package/src/stage-record.mjs +58 -13
  129. package/src/target-lock.mjs +11 -1
@@ -19,6 +19,7 @@ import { redactPersisted, redactUrlQueriesInText, redactUrlQuery } from "./qa-ur
19
19
  import { TRACKING_ADDED_BOUND_MS, TRACKING_OBSERVATION, createTrackingRun, trackingQaAssertion } from "./qa-tracking-params.mjs";
20
20
  import { CONTENT_PARAM_LIMITS, runContentParamChecks } from "./qa-content-params.mjs";
21
21
  import { createPolicyLinkBudget, hasPolicyLinkFields, readPageAnchors, runPolicyLinkChecks } from "./qa-policy-links.mjs";
22
+ import { vendorBeaconLedgerFor, withVendorBeaconRouting } from "./qa-vendor-beacons.mjs";
22
23
  import {
23
24
  canonicalHttpUrl,
24
25
  commonTestOrderPaths,
@@ -462,6 +463,8 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
462
463
  if (displayParity) assertions.push(displayParity);
463
464
  const totalParity = orderTotalParityAssertion(pageForPlan, identifier, result.order);
464
465
  if (totalParity) assertions.push(totalParity);
466
+ const currencyMatch = orderCurrencyAssertion(pageForPlan, identifier, result.order, args);
467
+ if (currencyMatch) assertions.push(currencyMatch);
465
468
  const renderedReceiptAssertion = receiptRenderingAssertion(pageForPlan, identifier, result.order);
466
469
  if (renderedReceiptAssertion) assertions.push(renderedReceiptAssertion);
467
470
  const dataLayerAssertion = purchaseDataLayerAssertion(pageForPlan, identifier, result.order);
@@ -4900,9 +4903,13 @@ export const QA_RUN_CURRENCY = Symbol("qa run --currency");
4900
4903
  // checkout opened directly. Campaign Cart keeps a URL currency in session
4901
4904
  // storage for the rest of the tab's session, so later loads in the tab are
4902
4905
  // the plain URL; the receipt reload in recovery is not a path load at all.
4903
- function pathCurrencyLoad(args) {
4906
+ function runCurrency(args) {
4904
4907
  const code = args?.[QA_RUN_CURRENCY];
4905
- let pending = typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
4908
+ return typeof code === "string" && /^[A-Z]{3}$/.test(code) ? code : null;
4909
+ }
4910
+
4911
+ function pathCurrencyLoad(args) {
4912
+ let pending = runCurrency(args);
4906
4913
  return (url) => {
4907
4914
  if (!pending) return url;
4908
4915
  const entered = withQueryParam(url, "currency", pending);
@@ -6638,6 +6645,31 @@ function orderTotalParityAssertion(page, planIdentifier, order) {
6638
6645
  });
6639
6646
  }
6640
6647
 
6648
+ // `qa run --currency <code>`: a created order must have been placed in the
6649
+ // run currency. Campaign Cart falls back to the default currency when the
6650
+ // requested one is not available, so an order recorded in any other currency
6651
+ // blocks, and so does a verified order whose currency was not recorded. No run
6652
+ // currency, or no order body read at all (the order's own row already says
6653
+ // why), gives no row.
6654
+ function orderCurrencyAssertion(page, planIdentifier, order, args) {
6655
+ const expected = runCurrency(args);
6656
+ if (!expected) return null;
6657
+ const recorded = stringArg(order?.verification?.currency);
6658
+ if (!recorded && order?.verification?.verified !== true) return null;
6659
+ const actual = recorded ? recorded.toUpperCase() : null;
6660
+ const ok = actual === expected;
6661
+ return assertion({
6662
+ id: `browser-order-currency:${planIdentifier}`,
6663
+ family: "browser-test-order",
6664
+ page,
6665
+ status: ok ? STATUS.PASS : STATUS.FAIL,
6666
+ severity: ok ? undefined : SEVERITY.BLOCKER,
6667
+ expected: `the order is placed in the run currency ${expected}`,
6668
+ actual: actual ? `order placed in ${actual}` : "the order's currency was not recorded",
6669
+ evidence: { requested: expected, order_currency: actual },
6670
+ });
6671
+ }
6672
+
6641
6673
  // --- What a failed attempt did to the store ----------------------------------
6642
6674
  //
6643
6675
  // This replaces the old `shouldRetryTestOrder` boolean, which asked only whether
@@ -8873,8 +8905,11 @@ function trim(value) {
8873
8905
  return String(value || "").replace(/\s+/g, " ").trim();
8874
8906
  }
8875
8907
 
8876
- function launchChromium(args) {
8877
- return launchPackageChromium({ headed: args.headed, onMissing: qaBrowserMissing });
8908
+ // Every browser QA leg launches here, so on a test-order run every context it
8909
+ // opens answers the vendor beacon endpoints locally (qa-vendor-beacons.mjs).
8910
+ async function launchChromium(args) {
8911
+ const browser = await launchPackageChromium({ headed: args.headed, onMissing: qaBrowserMissing });
8912
+ return withVendorBeaconRouting(browser, vendorBeaconLedgerFor(args));
8878
8913
  }
8879
8914
 
8880
8915
  function qaBrowserMissing(kind, error) {
@@ -8893,6 +8928,7 @@ function qaBrowserMissing(kind, error) {
8893
8928
  }
8894
8929
 
8895
8930
  export const __qaBrowserTestHooks = Object.freeze({
8931
+ orderCurrencyAssertion,
8896
8932
  qaBrowserMissing,
8897
8933
  PRIMARY_CTA_SELECTOR,
8898
8934
  COUPON_INPUT_SELECTORS,
@@ -379,7 +379,9 @@ export async function mapConcurrent(values, limit, operation) {
379
379
  return results;
380
380
  }
381
381
 
382
- export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS.max_scenarios } = {}) {
382
+ // `currency` is the run's currency (`qa run --currency`): every calculate
383
+ // request asks for it instead of the Map's campaign currency.
384
+ export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS.max_scenarios, currency = null } = {}) {
383
385
  const limit = positiveLimit(maxScenarios, COMMERCIAL_QA_LIMITS.max_scenarios);
384
386
  const pages = commercialSpecPages(spec, { maxPages: limit + 1 });
385
387
  const plan = [];
@@ -391,7 +393,7 @@ export function planCommercialParity(spec, { maxScenarios = COMMERCIAL_QA_LIMITS
391
393
  break;
392
394
  }
393
395
  try {
394
- plan.push(...planScenarios(page, spec, { maxScenarios: remaining }));
396
+ plan.push(...planScenarios(page, spec, { maxScenarios: remaining, currency }));
395
397
  } catch (error) {
396
398
  if (!(error instanceof CommercialJourneyLimitError)) throw error;
397
399
  overflow = true;
@@ -502,6 +504,9 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
502
504
  extracted_voucher_claims: report.extracted_voucher_claims,
503
505
  compared_voucher_claims: report.compared_voucher_claims,
504
506
  unresolved_voucher_claims: report.unresolved_voucher_claims,
507
+ ...(present(report.unresolved_money_claims_reason)
508
+ ? { unresolved_money_claims_reason: report.unresolved_money_claims_reason }
509
+ : {}),
505
510
  serialized_assertion_count: report.serialized_assertion_count,
506
511
  omitted_assertion_count: report.omitted_assertion_count,
507
512
  recurring_claim_absences: array(report.recurring_claim_absences),
@@ -581,6 +586,31 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
581
586
  };
582
587
  }
583
588
 
589
+ // One blocker row per currency the calculate endpoint answered in other than
590
+ // the run's: expected totals were asked for in `currency` and came back priced
591
+ // in another one. Answered codes are compared and named upper-cased, so `usd`
592
+ // and `USD` are one row. The rows are few (one per answered currency) and sit
593
+ // outside the assertion budget, so a full budget never hides the blocker.
594
+ function runCurrencyMismatchAssertions(journey, currency) {
595
+ const requested = String(currency).toUpperCase();
596
+ const answered = new Set();
597
+ array(journey?.warnings).forEach((warning) => {
598
+ if (warning?.type !== "currency_mismatch" || String(warning.requested).toUpperCase() !== requested) return;
599
+ const actual = String(warning.actual).toUpperCase();
600
+ if (actual !== requested) answered.add(actual);
601
+ });
602
+ return [...answered].map((actual) => ({
603
+ id: `commercial-currency:${requested}:${encodeURIComponent(actual)}`,
604
+ family: "pricing",
605
+ page: "campaign",
606
+ status: "fail",
607
+ severity: "blocker",
608
+ expected: `expected totals calculated in the run currency ${requested}`,
609
+ actual: `the calculate endpoint priced in ${actual}`,
610
+ evidence: { code: "currency_mismatch", requested, actual },
611
+ }));
612
+ }
613
+
584
614
  /**
585
615
  * Run the portable commercial planner against the public proxy contract and
586
616
  * serialize proven mismatches into ordinary QA assertions.
@@ -592,6 +622,7 @@ export async function runCommercialParity({
592
622
  limits: limitOverrides = {},
593
623
  planning = null,
594
624
  maxAssertions = Number.POSITIVE_INFINITY,
625
+ currency = null,
595
626
  } = {}) {
596
627
  const limits = {
597
628
  ...COMMERCIAL_QA_LIMITS,
@@ -603,6 +634,7 @@ export async function runCommercialParity({
603
634
  const planningSpec = resolved?.rawSpec || resolved?.spec || {};
604
635
  const commercialPlanning = planning || planCommercialParity(planningSpec, {
605
636
  maxScenarios: limits.max_scenarios,
637
+ currency,
606
638
  });
607
639
  const plan = commercialPlanning.plan;
608
640
  const scenarioOverflow = commercialPlanning.overflow || plan.length > limits.max_scenarios;
@@ -654,9 +686,24 @@ export async function runCommercialParity({
654
686
  const parityJourney = scenarioOverflow
655
687
  ? withScenarioOverflowPages(journey, commercialPlanning.pages)
656
688
  : journey;
689
+ // Under a run currency, a calculate response in another currency means the
690
+ // campaign does not price in it: a blocker, and the comparison below is
691
+ // against totals in the wrong currency, so the proof is incomplete. Without
692
+ // one, the journey's currency_mismatch warning stays where it is.
693
+ const currencyAssertions = present(currency) ? runCurrencyMismatchAssertions(journey, currency) : [];
694
+ currencyAssertions.forEach(() => issues.push({ code: "calculate_currency_mismatch" }));
695
+ // Captures read the page's source HTML, whose bound prices are the authored
696
+ // fallback text in the Map's campaign currency (the SDK replaces them at
697
+ // runtime). Under a run currency the Map does not name, those amounts cannot
698
+ // be compared with totals in the run currency: they stay unresolved, with
699
+ // the reason recorded, instead of reading as mismatches.
700
+ const sourceCurrency = planningSpec?.campaign?.currency ?? planningSpec?.currency;
701
+ const sourceNotInRunCurrency = present(currency)
702
+ && String(sourceCurrency ?? "").toUpperCase() !== String(currency).toUpperCase();
657
703
  const parity = createCommercialParityReport(captures, parityJourney, {
658
704
  maxAssertions,
659
705
  countsOnly: aggregateClaimOverflow,
706
+ ...(sourceNotInRunCurrency ? { unresolvedMoneyClaims: "static_source_not_in_run_currency" } : {}),
660
707
  });
661
708
  // Subscription packages a page renders with no recurring claim read for
662
709
  // them: incomplete evidence (an issue), and a warning naming the package.
@@ -669,7 +716,7 @@ export async function runCommercialParity({
669
716
  });
670
717
  parity.recurring_claim_absences = absences;
671
718
  return {
672
- assertions: [...parity.assertions, ...absentAssertions],
719
+ assertions: [...currencyAssertions, ...parity.assertions, ...absentAssertions],
673
720
  commercial: compactReport(
674
721
  parity,
675
722
  parityJourney,
package/src/qa-node.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
2
- import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, ensureUrlTrailingSlash, normalizeQaBaseUrl, starterResidueIsExpected } from "./local-preview-policy.mjs";
2
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, applyPageOnlyChangeToCheckpoint, applyPageOnlyChangeToPolishGate, carriedForwardMessage, CARRIED_FORWARD, ensureUrlTrailingSlash, isPageOnlyCarry, normalizeQaBaseUrl, starterResidueIsExpected } from "./local-preview-policy.mjs";
3
+ import { assessEvidenceDebt } from "./evidence-debt.mjs";
4
+ import { routePageIdForUrl } from "./change-scope.mjs";
3
5
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
4
6
  import { shellToken } from "./shell-token.mjs";
5
- import { currentBriefMaterial } from "./input-currency.mjs";
7
+ import { currentBriefMaterial, deriveInputCurrency } from "./input-currency.mjs";
6
8
  import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
7
9
  import { requiredActionText } from "./gate-actions.mjs";
8
10
  import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
@@ -37,6 +39,7 @@ import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
37
39
  import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
38
40
  import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
39
41
  import { policyLinkNotRequestedRows, policyLinkQaAssertion } from "./qa-policy-links.mjs";
42
+ import { createVendorBeaconLedger, QA_VENDOR_BEACONS, vendorBeaconAssertion, vendorBeaconMode } from "./qa-vendor-beacons.mjs";
40
43
  import { createVerdict, formatTestOrderSummary, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
41
44
  import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
42
45
  import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
@@ -78,12 +81,12 @@ import {
78
81
  } from "./polish-capture.mjs";
79
82
  import { resolveConsent } from "./consent.mjs";
80
83
  import { markDoctorSidecarStale } from "./doctor-sidecar.mjs";
81
- import { commitAssemblyReport } from "./stage-ledger.mjs";
84
+ import { commitAssemblyReport, qaExtendedBuildFingerprint } from "./stage-ledger.mjs";
82
85
  // Tags a refusal raised before a qa handler runs, so the CLI's lifecycle
83
86
  // persist step suppresses the journal append from one place. lifecycle.mjs
84
87
  // imports nothing from this repository, so this cannot be circular.
85
88
  import { refused, refusing } from "./lifecycle.mjs";
86
- import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
89
+ import { campaignSidecarPaths, explicitReportPath, packetRuntimeRelPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
87
90
  import { loadParityFixture } from "./qa-parity-fixture.mjs";
88
91
  import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
89
92
  import { loadPageKitCampaignEntry, PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
@@ -131,7 +134,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
131
134
  Usage:
132
135
  campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
133
136
  campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
134
- campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
137
+ campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--page <page_id>]... [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
135
138
  campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
136
139
  campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
137
140
  campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
@@ -147,6 +150,9 @@ Options:
147
150
  --baseline <url> Optional live analytics baseline for qa parity.
148
151
  --parity-order-json <path> Assessment-only saved order + analytics capture bundle; skips Playwright.
149
152
  --packet <path> Read Map ID, local CampaignSpec, deploy URL, and QA metadata from a Build Packet.
153
+ --page <page_id> Check only this page; repeat for more pages. Pays the QA a page-only change
154
+ owes on those pages (doctor --ci and next name them). Packet QA only; places no
155
+ test orders, and refuses a change that touches commerce or leaves a changed page out.
150
156
  --site <path> L7 non-packet QA: resolve scope (pages + funnel types) from a built page-kit _site/.
151
157
  Requires --base-url and --family. No Map ID / CampaignSpec needed.
152
158
  --spec <path> Local exported CampaignSpec JSON for the non-packet Map ID flow.
@@ -195,7 +201,10 @@ Options:
195
201
  --currency <code> qa run: enter each funnel's entry page with ?currency=<CODE> (three letters, e.g. GBP),
196
202
  keeping its existing query. The flag takes one code per run (given twice, the
197
203
  last one is used); the verdict records it as evidence.currency on the entry
198
- page's http:<page> row. Not supported by qa parity or --legacy-api-test-order.
204
+ page's http:<page> row. Expected totals are calculated in the code, and a test
205
+ order or calculate response in another currency is a blocker (exit 4) on its
206
+ browser-order-currency:<path> or commercial-currency:<CODE>:<ANSWERED> row.
207
+ Not supported by qa parity or --legacy-api-test-order.
199
208
  --auth-cookie <cookie> Cookie header for protected previews.
200
209
  --browser Run Playwright-rendered browser checks after static Node checks.
201
210
  Requires one-time setup: campaigns-os qa install-browser
@@ -229,6 +238,15 @@ Options:
229
238
  --max-order-creations <n> Hard bound on REAL order creations in this run, reserved before each submit
230
239
  click. Default: the planned path count. A path whose failure is confirmed to
231
240
  have created an order is inspected read-only, never resubmitted.
241
+ --vendor-beacons live qa run --test-order: let the page's analytics vendor beacons reach the vendors.
242
+ By default a test-order run answers requests to the listed vendor beacon
243
+ endpoints (GA4, Meta, TikTok, Snap, Pinterest, Taboola, Triple Whale, AppLovin
244
+ Axon) locally in every browser context, so test purchases stay out of vendor
245
+ data; loader scripts still load. The verdict row browser-test-order:vendor-beacons
246
+ records the requests per vendor as evidence, never as purchase proof, and names
247
+ the vendors not intercepted (Northbeam; GTM, which sends nothing of its own).
248
+ live is the only value; any other is refused, as is the flag without
249
+ --test-order.
232
250
  --allowed-domains-confirmed <bool>
233
251
  qa policy set: persist non-localhost SDK-origin confirmation.
234
252
  Localhost on any port is a global Development domain with analytics suppressed.
@@ -524,12 +542,25 @@ async function resolveQaInputs(args, {
524
542
  hiddenEagerMediaGate,
525
543
  packet,
526
544
  baseUrl: stringArg(args["base-url"]),
545
+ evidenceDebt: checkpointPreflight?.evidenceDebt || null,
527
546
  });
528
547
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
529
- const qaScope = applyQaBuildScope(topologies, {
548
+ const buildScope = applyQaBuildScope(topologies, {
530
549
  packet, report: checkpointPreflight?.runtimeReport,
531
550
  targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
532
551
  });
552
+ const requestedPages = qaPageArgs(args);
553
+ const pageScope = applyQaPageScope(buildScope.topologies, {
554
+ pages: requestedPages,
555
+ baseUrl,
556
+ debt: checkpointPreflight?.evidenceDebt || null,
557
+ report: checkpointPreflight?.runtimeReport,
558
+ // Read against the spec this run judges, as doctor reads it.
559
+ qaInputCurrency: requestedPages
560
+ ? deriveInputCurrency({ packet, packetPath, report: checkpointPreflight?.runtimeReport || null, spec: isPlainObject(rawSpec) ? rawSpec : null }).stages.qa
561
+ : undefined,
562
+ });
563
+ const qaScope = { ...buildScope, topologies: pageScope.topologies };
533
564
  const brandContract = loadBrandContract(templateFamily);
534
565
  const localServeAnalytics = resolveLocalServeAnalytics({
535
566
  packet,
@@ -565,6 +596,7 @@ async function resolveQaInputs(args, {
565
596
  commerceStructureContract,
566
597
  topologies: qaScope.topologies,
567
598
  excludedPages: qaScope.excludedPages,
599
+ pageScope: pageScope.scope,
568
600
  checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
569
601
  // The report the checkpoint gates were evaluated on, and the target repo
570
602
  // whose default it may or may not be: what the printed remediation names.
@@ -637,13 +669,18 @@ function resolvePacketCheckpointPreflight(args, {
637
669
  waivers: report?.waivers,
638
670
  required: true,
639
671
  }),
640
- applyLocalPreviewToCheckpoint(
641
- evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
642
- { packet, report, baseUrl: stringArg(args["base-url"]) },
643
- ),
644
672
  ];
645
673
  const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
674
+ // The evidence debt doctor and readback assess: a page-only change since
675
+ // Polish last reviewed the campaign makes Polish advisory (#720), and a
676
+ // page-scoped run (--page) pays the QA it owes.
677
+ const evidenceDebt = assessEvidenceDebt({ packet, packetPath, report: runtimeReport });
678
+ checkpointGates.push(applyPageOnlyChangeToCheckpoint(applyLocalPreviewToCheckpoint(
679
+ evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
680
+ { packet, report, baseUrl: stringArg(args["base-url"]) },
681
+ ), evidenceDebt));
646
682
  return {
683
+ evidenceDebt,
647
684
  packetPath,
648
685
  packet,
649
686
  specPath,
@@ -980,22 +1017,125 @@ function currentBuiltOutputFingerprint(packetPath) {
980
1017
  }
981
1018
  }
982
1019
 
1020
+ // qa run --page <page_id> (#720): repeat the flag or separate ids with
1021
+ // commas. Null when the run is not page-scoped.
1022
+ function qaPageArgs(args) {
1023
+ if (args.page === undefined) return null;
1024
+ const values = Array.isArray(args.page) ? args.page : [args.page];
1025
+ const ids = values.flatMap((value) => (typeof value === "string" ? value.split(",") : [""])).map((id) => id.trim());
1026
+ if (!ids.length || ids.some((id) => !id)) {
1027
+ throw refused("--page takes a page id: the id `doctor --ci` or `next` names for a changed page, or the CampaignSpec page id. Repeat --page for more pages.");
1028
+ }
1029
+ return [...new Set(ids)];
1030
+ }
1031
+
1032
+ // A page-scoped run checks pages, not purchases: it pays the QA a page-only
1033
+ // change owes, and a change that owes test orders owes a full run.
1034
+ function refuseBadPageScope(args) {
1035
+ if (qaPageArgs(args) === null) return;
1036
+ if (!stringArg(args.packet)) throw refused("--page needs --packet: a page-scoped run pays the QA a recorded build owes, which only a Build Packet's Assembly Report records.");
1037
+ for (const flag of ["test-order", "legacy-api-test-order"]) {
1038
+ if (args[flag] !== undefined && String(args[flag]).toLowerCase() !== "off") {
1039
+ throw refused(`--page cannot be combined with --${flag}: a page-scoped run checks the named pages and places no test orders. Run ${cmd("qa")} run without --page for purchase proof.`);
1040
+ }
1041
+ }
1042
+ if (args["vendor-beacons"] !== undefined) {
1043
+ throw refused("--page cannot be combined with --vendor-beacons: a page-scoped run places no test orders, so there are no vendor beacons to route. Drop --vendor-beacons.");
1044
+ }
1045
+ }
1046
+
1047
+ // Narrow the QA topology to the pages --page names, after checking that the
1048
+ // run can pay what QA owes: the output on disk is the recorded build, the
1049
+ // change since the build QA reviewed is page-only, and the named pages cover
1050
+ // every changed page. The run extends a completed QA, and QA's inputs (the
1051
+ // brief's qa_policy and the CampaignSpec) are current: `qaInputCurrency` is
1052
+ // derived.input_currency.stages.qa, and a QA an input change owes, or whose
1053
+ // inputs cannot be confirmed, owes a full run. Re-running a page-scoped
1054
+ // record for the same build is allowed too. Each requested id is a topology
1055
+ // page id or the page id of its route (the id the change lists use).
1056
+ export function applyQaPageScope(topologies, { pages, baseUrl, debt, report, qaInputCurrency = undefined } = {}) {
1057
+ if (!pages) return { topologies, scope: null };
1058
+ const all = topologies.flatMap((topology) => topology.pages || []);
1059
+ const idsOf = (page) => [String(page.page_id), page.url && baseUrl ? routePageIdForUrl(page.url, baseUrl) : null].filter(Boolean);
1060
+ const known = new Set(all.flatMap(idsOf));
1061
+ const unknown = pages.filter((id) => !known.has(id));
1062
+ if (unknown.length) {
1063
+ throw new Error(`qa run --page: ${unknown.map((id) => JSON.stringify(id)).join(", ")} is not a page of this campaign's QA topology. Pages: ${[...new Set(all.map((page) => String(page.page_id)))].join(", ") || "none"}.`);
1064
+ }
1065
+ const selected = new Set(pages);
1066
+ const isSelected = (page) => idsOf(page).some((id) => selected.has(id));
1067
+ const selectedRouteIds = new Set(all.filter(isSelected).flatMap(idsOf));
1068
+ const qa = debt?.qa;
1069
+ const changes = debt?.changes;
1070
+ if (!qa || !changes?.current_build_fingerprint) {
1071
+ throw new Error("qa run --page needs the Assembly Report and the built output under _site/<public_route_slug>/ to read what QA owes; run it without --page.");
1072
+ }
1073
+ if (changes.recorded_build_fingerprint !== changes.current_build_fingerprint) {
1074
+ throw new Error(`qa run --page: the built output is not the recorded build; run ${cmd("record")} build --packet <packet> first, so the page-scoped verdict is bound to the output it checked.`);
1075
+ }
1076
+ const extended = qaExtendedBuildFingerprint(report);
1077
+ const previousScope = report?.stages?.qa?.evidence?.page_scope;
1078
+ let base = qa.change?.since_build_fingerprint || qa.recorded_build_fingerprint;
1079
+ if (qa.status === "current") {
1080
+ const previousPages = Array.isArray(previousScope?.owed_pages) ? previousScope.owed_pages : null;
1081
+ if (!previousPages || !previousPages.every((id) => selectedRouteIds.has(id))) {
1082
+ throw new Error(`qa run --page: QA is already recorded for this build. Run ${cmd("qa")} run without --page to re-run it in full.`);
1083
+ }
1084
+ base = previousScope.base_build_fingerprint || base;
1085
+ } else if (qa.status !== "owed" || qa.scope !== "pages") {
1086
+ const kind = qa.change?.kind === "commerce_affecting" ? "commerce-affecting"
1087
+ : qa.status === "owed" && qa.change?.kind === "page_only"
1088
+ ? extended ? "page-only, but QA is required again by an input change" : "page-only, but the QA recorded before it did not complete"
1089
+ : "not classified as page-only";
1090
+ throw new Error(`qa run --page: the change since the build QA reviewed is ${kind}${(qa.change?.reasons || []).length ? ` (${[...new Set(qa.change.reasons.map((reason) => reason.code))].join(", ")})` : ""}, so it owes a full QA run. Run ${cmd("qa")} run without --page.`);
1091
+ } else {
1092
+ const uncovered = qa.owed_pages.filter((page) => !selectedRouteIds.has(page.page_id));
1093
+ if (uncovered.length) {
1094
+ throw new Error(`qa run --page: QA is owed on ${qa.owed_pages.map((page) => page.page_id).join(", ")}; add ${uncovered.map((page) => `--page ${page.page_id}`).join(" ")}.`);
1095
+ }
1096
+ }
1097
+ if (qaInputCurrency === "owed" || qaInputCurrency === "unknown") {
1098
+ throw new Error(`qa run --page: ${qaInputCurrency === "owed" ? "an input change (the brief's qa_policy or the CampaignSpec) owes QA again" : "QA's inputs (the brief and the CampaignSpec) cannot be confirmed current"}, so it owes a full QA run. Run ${cmd("qa")} run without --page.`);
1099
+ }
1100
+ if (!extended) {
1101
+ throw new Error(`qa run --page: the QA recorded before this change did not complete${report?.stages?.qa?.status ? ` (status ${JSON.stringify(String(report.stages.qa.status))})` : ""}, so there is no passed QA to extend and it owes a full QA run. Run ${cmd("qa")} run without --page.`);
1102
+ }
1103
+ const excluded = new Map();
1104
+ const scoped = topologies.map((topology) => {
1105
+ const kept = (topology.pages || []).filter(isSelected);
1106
+ for (const page of topology.pages || []) if (!isSelected(page)) excluded.set(String(page.page_id), page);
1107
+ return kept.length === (topology.pages || []).length ? topology : { ...topology, partial_build_scope: true, pages: kept };
1108
+ }).filter((topology) => (topology.pages || []).length);
1109
+ const owedPages = qa.status === "current" ? previousScope.owed_pages : qa.owed_pages.map((page) => page.page_id);
1110
+ return {
1111
+ topologies: scoped,
1112
+ scope: {
1113
+ pages: [...new Set(all.filter(isSelected).map((page) => String(page.page_id)))],
1114
+ owed_pages: owedPages,
1115
+ base_build_fingerprint: base,
1116
+ build_fingerprint: changes.current_build_fingerprint,
1117
+ excluded: [...excluded.values()].filter((page) => !all.some((other) => isSelected(other) && String(other.page_id) === String(page.page_id))),
1118
+ },
1119
+ };
1120
+ }
1121
+
983
1122
  function resolvePolishGate({
984
1123
  packetPath,
985
1124
  report: reportOverride = undefined,
986
1125
  hiddenEagerMediaGate = undefined,
987
1126
  packet = null,
988
1127
  baseUrl = null,
1128
+ evidenceDebt = null,
989
1129
  }) {
990
1130
  const report = reportOverride === undefined
991
1131
  ? loadRuntimeArtifact(packetPath, "assembly-report.json")
992
1132
  : reportOverride;
993
- const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
1133
+ const gate = applyPageOnlyChangeToPolishGate(applyLocalPreviewToPolishGate(evaluatePolishGate({
994
1134
  report,
995
1135
  required: true,
996
1136
  hiddenEagerMediaGate,
997
1137
  currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
998
- }), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
1138
+ }), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl }), { debt: evidenceDebt, checkpointGate: hiddenEagerMediaGate });
999
1139
  gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
1000
1140
  return gate;
1001
1141
  }
@@ -1101,6 +1241,19 @@ function polishGateAssertion(gate) {
1101
1241
  },
1102
1242
  });
1103
1243
  }
1244
+ if (isPageOnlyCarry(gate)) {
1245
+ // Advisory for a page-only change (#720): reported, not a warning that
1246
+ // would hold the verdict to ready_with_exceptions.
1247
+ return assertion({
1248
+ id: gate.code,
1249
+ family: "polish_gate",
1250
+ page,
1251
+ status: STATUS.SKIPPED,
1252
+ expected: "current structured Polish evidence, or a page-only change since Polish last reviewed the campaign",
1253
+ actual: carriedForwardMessage(gate),
1254
+ evidence: { ...evidence, reason: gate.reason, carried_forward: gate.carried_forward },
1255
+ });
1256
+ }
1104
1257
  if (gate.status === CARRIED_FORWARD) {
1105
1258
  return assertion({
1106
1259
  id: gate.code,
@@ -1419,6 +1572,9 @@ function hiddenEagerMediaGateAssertion(gate) {
1419
1572
  if (summary.status === "waived") {
1420
1573
  return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
1421
1574
  }
1575
+ if (isPageOnlyCarry(summary)) {
1576
+ return assertion({ ...common, status: STATUS.SKIPPED, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
1577
+ }
1422
1578
  if (summary.status === CARRIED_FORWARD) {
1423
1579
  return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
1424
1580
  }
@@ -1624,7 +1780,7 @@ function serializeThrownValue(error) {
1624
1780
  // `derived.assembly_report_path` handling — so the printed remediations can
1625
1781
  // name it and a default-report campaign's output is unchanged.
1626
1782
  function reportPathField(resolved) {
1627
- const reportPath = explicitReportPath(resolved?.reportPath, resolved?.targetRepo);
1783
+ const reportPath = explicitReportPath(resolved?.reportPath, resolved?.targetRepo, packetRuntimeRelPath(resolved?.packet));
1628
1784
  return reportPath ? { report_path: reportPath } : {};
1629
1785
  }
1630
1786
 
@@ -1919,6 +2075,7 @@ function updateQaPolicy(args) {
1919
2075
  // #171: packet edits change what doctor would conclude; the retained
1920
2076
  // doctor sidecar (if any) now predates them.
1921
2077
  markDoctorSidecarStale(targetRepoFor(packetPath, packet), {
2078
+ path: campaignSidecarPaths(targetRepoFor(packetPath, packet), packetRuntimeRelPath(packet)).doctorOutPath,
1922
2079
  command: "qa policy set",
1923
2080
  reason: staleReason,
1924
2081
  });
@@ -2285,6 +2442,7 @@ async function runQa(args, options = {}) {
2285
2442
  // Fail-fast before anything resolves or launches. The authoritative check
2286
2443
  // lives on the creation budget itself, which every browser path builds.
2287
2444
  refuseBadOrderCreationLimit(args);
2445
+ refuseBadPageScope(args);
2288
2446
  warnCurrencyWithoutTestCountry(args);
2289
2447
  // Match dispatch: an active browser mode takes precedence over legacy API
2290
2448
  // diagnostics. Only the selected legacy path requires a cart and API mode.
@@ -2293,6 +2451,7 @@ async function runQa(args, options = {}) {
2293
2451
  if (browserMode === "off" && legacyMode !== "off") {
2294
2452
  refusing(() => legacyTestOrderInputs({ ...args, "test-order": legacyMode }));
2295
2453
  }
2454
+ vendorBeaconMode(args);
2296
2455
  const resolved = await resolveQaInputs(args);
2297
2456
  return runResolvedQa(args, resolved, options);
2298
2457
  }
@@ -2385,12 +2544,20 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2385
2544
  actual: "out_of_build_scope",
2386
2545
  evidence: { reason: "out_of_build_scope" },
2387
2546
  })),
2547
+ ...(resolved.pageScope?.excluded || []).map(page => assertion({
2548
+ id: `page-scope:${page.page_id}`, family: "funnel-flow", page,
2549
+ status: STATUS.SKIPPED,
2550
+ expected: "A page-scoped run (--page) checks only the named pages",
2551
+ actual: "outside_page_scope",
2552
+ evidence: { reason: "outside_page_scope" },
2553
+ })),
2388
2554
  ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
2389
2555
  themeGateAssertion(gate),
2390
2556
  ];
2391
2557
  const contractAssertion = templateBrandContractAssertion(resolved);
2392
2558
  if (contractAssertion) assertions.push(contractAssertion);
2393
- const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, resolved.excludedPages));
2559
+ // Under --currency the expected totals are calculated in the run currency.
2560
+ const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, [...(resolved.excludedPages || []), ...(resolved.pageScope?.excluded || [])]), { currency });
2394
2561
  const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
2395
2562
  const commercialIds = new Set(commercialPlanning.pages
2396
2563
  .filter((page) => page?.id !== undefined && page?.id !== null)
@@ -2435,6 +2602,12 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2435
2602
  proxyBase: resolved.proxyBase,
2436
2603
  });
2437
2604
  assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
2605
+ // A test-order run answers vendor beacons locally in every browser context
2606
+ // it opens, unless --vendor-beacons live; the ledger rides on args to each
2607
+ // browser leg and becomes one evidence row (qa-vendor-beacons.mjs).
2608
+ const vendorBeaconRun = vendorBeaconMode(args);
2609
+ const vendorBeacons = vendorBeaconRun ? createVendorBeaconLedger({ mode: vendorBeaconRun, analytics: resolved.spec?.analytics }) : null;
2610
+ if (vendorBeacons) args = { ...args, [QA_VENDOR_BEACONS]: vendorBeacons };
2438
2611
  const qcResults = [];
2439
2612
  if (args.browser === true) {
2440
2613
  const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
@@ -2470,6 +2643,7 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2470
2643
  }
2471
2644
 
2472
2645
  const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions, qcResults });
2646
+ if (vendorBeacons) assertions.push(vendorBeaconAssertion(vendorBeacons));
2473
2647
  const remainingAssertionBudget = Math.max(0, QA_VERDICT_ASSERTION_LIMIT - assertions.length);
2474
2648
  let commercialResult;
2475
2649
  try {
@@ -2478,6 +2652,7 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2478
2652
  captures: [...capturesByPageId.values()],
2479
2653
  planning: commercialPlanning,
2480
2654
  maxAssertions: remainingAssertionBudget,
2655
+ currency,
2481
2656
  });
2482
2657
  } catch (error) {
2483
2658
  commercialResult = {
@@ -2692,6 +2867,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2692
2867
  // `external:qa_verdict` whenever that is not the packet directory.
2693
2868
  const causeSummary = annotateQaAssertionCauses(assertions, {
2694
2869
  baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
2870
+ runtimeRelPath: resolved.packetPath ? packetRuntimeRelPath(resolved.packet) : undefined,
2695
2871
  targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
2696
2872
  mapId: resolved.mapId,
2697
2873
  localSpecId: resolved.localSpecId,
@@ -2807,6 +2983,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2807
2983
  // The QC rows the QA stage records; readers re-derive them from the qc.*
2808
2984
  // assertions of the verdict written above.
2809
2985
  qc_results: qcResults,
2986
+ // qa run --page: the pages this run checked and the change it pays for,
2987
+ // which the QA stage record keeps (cli.mjs recordQaStageOutcome).
2988
+ ...(resolved.pageScope ? { page_scope: resolved.pageScope } : {}),
2810
2989
  verdict,
2811
2990
  };
2812
2991
  }
@@ -2824,7 +3003,8 @@ function analyticsCaptureScope(resolved) {
2824
3003
  const rootUrl = rootCaptureUrl(resolved?.analyticsCaptureTarget?.url);
2825
3004
  const topologies = topologyList(resolved?.topologies);
2826
3005
  const partial = topologies.some((topology) => topology?.partial_build_scope)
2827
- || (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0);
3006
+ || (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0)
3007
+ || Boolean(resolved?.pageScope?.excluded?.length);
2828
3008
  const rootInScope = !rootUrl || !partial || topologies.some((topology) =>
2829
3009
  (Array.isArray(topology?.pages) ? topology.pages : []).some((page) =>
2830
3010
  typeof page?.url === "string" && isSameAnalyticsCapturePage(page.url, rootUrl)));
@@ -19,9 +19,9 @@ import { createHash } from "node:crypto";
19
19
  import { existsSync, readFileSync } from "node:fs";
20
20
  import { basename, dirname, join, resolve } from "node:path";
21
21
 
22
- import { campaignSidecarPaths, targetRepoFor } from "./campaign-workspace.mjs";
22
+ import { campaignSidecarPaths, packetRuntimeRelPath, targetRepoFor } from "./campaign-workspace.mjs";
23
23
  import { refused } from "./lifecycle.mjs";
24
- import { SIDECAR_RELATIVE_PATH } from "./qa-sidecar.mjs";
24
+ import { sidecarPathForPacket } from "./qa-sidecar.mjs";
25
25
  import { qaVerdictIdentityMatch } from "./qa-verdict-discovery.mjs";
26
26
  import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISH_ENDPOINT, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
27
27
  import { validateVerdict } from "./qa-verdict.mjs";
@@ -108,9 +108,9 @@ export function resolveStoredVerdictSource({ args, packetPath, packet, readJsonF
108
108
  if (explicit) {
109
109
  const path = resolve(explicit);
110
110
  if (!exists(path)) return { error: refusal(QA_PUBLISH_REFUSALS.verdict_missing, `No verdict file at ${path}.`) };
111
- return { path, source_kind: path === sidecarPath(packetPath) ? "sidecar_projection" : "explicit" };
111
+ return { path, source_kind: path === sidecarPath(packetPath, packet) ? "sidecar_projection" : "explicit" };
112
112
  }
113
- const sidecar = sidecarPath(packetPath);
113
+ const sidecar = sidecarPath(packetPath, packet);
114
114
  if (!exists(sidecar)) {
115
115
  return {
116
116
  error: refusal(
@@ -139,8 +139,8 @@ export function resolveStoredVerdictSource({ args, packetPath, packet, readJsonF
139
139
  return { path: sidecar, source_kind: "sidecar_projection", sidecar_path: sidecar };
140
140
  }
141
141
 
142
- function sidecarPath(packetPath) {
143
- return join(dirname(resolve(packetPath)), SIDECAR_RELATIVE_PATH);
142
+ function sidecarPath(packetPath, packet) {
143
+ return sidecarPathForPacket(packetPath, packet);
144
144
  }
145
145
 
146
146
  /**
@@ -282,7 +282,8 @@ async function attemptPublish(args, operations, dryRun) {
282
282
  } catch {
283
283
  verdictDigest = null;
284
284
  }
285
- const records = ops.readRecords(baseDir);
285
+ const runtimeRelPath = packetRuntimeRelPath(packet);
286
+ const records = ops.readRecords(baseDir, runtimeRelPath);
286
287
  const recordEntry = findRunRecordForVerdict({ records, packet, verdictRunId, verdictDigest });
287
288
  const priorBlock = recordEntry?.record?.qa_verdict_publish?.verdict_run_id === verdictRunId ? recordEntry.record.qa_verdict_publish : null;
288
289
  const republish = args.republish === true;
@@ -377,7 +378,7 @@ async function attemptPublish(args, operations, dryRun) {
377
378
  } else {
378
379
  try {
379
380
  const record = { ...recordEntry.record, qa_verdict_publish: stamped };
380
- const path = ops.writeRecord(record, { baseDir });
381
+ const path = ops.writeRecord(record, { baseDir, runtimeRelPath });
381
382
  runRecord = { ...recordSummary({ path, record }, stamped), written: true, preserved: false };
382
383
  } catch (error) {
383
384
  runRecord = { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false, error: singleLineFragment(error.message) };