@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  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 +29 -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 +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
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,14 +134,14 @@ 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
138
141
  campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
139
142
  campaigns-os qa resolve <map-id> --spec <campaign-spec.json> [--base-url <url>]
140
143
  campaigns-os qa run <map-id> --spec <campaign-spec.json> --base-url <url>
141
- campaigns-os qa run --site <page-kit-target-repo> --base-url <url> --family <family> [--slug <slug>] [--browser] # L7: QA a built _site/ with no packet/spec
144
+ campaigns-os qa run --site <page-kit-target-repo> --base-url <url> --family <family> [--slug <slug>] [--currency <code>] [--browser] # L7: QA a built _site/ with no packet/spec
142
145
  campaigns-os qa install-browser [--json] # one-time: install the package-owned Playwright Chromium (same as npm run qa:install-browser from a checkout)
143
146
 
144
147
  Options:
@@ -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.
@@ -249,6 +267,19 @@ Options:
249
267
  --test-exp-year <yyyy> Test card expiration year. Default: 2030.
250
268
  --test-email <email> Customer email for browser test orders. Env CAMPAIGNS_OS_QA_TEST_EMAIL is also recognized.
251
269
  --test-email-prefix <prefix> Legacy unique email prefix override.
270
+ --test-first-name <name> Test customer first name. Default: QA.
271
+ --test-last-name <name> Test customer last name. Default: Playwright.
272
+ --test-phone <phone> Test customer phone, filled when the checkout has the field. Default: empty.
273
+ --test-country <code> Shipping (and billing) country, as the checkout's country select option value
274
+ or label (e.g. GB). Default: US. Changing it does not change the run's currency:
275
+ pair it with --currency <code>, and pass the four address flags below for that
276
+ country, since their defaults are a US address. A non-USD --currency on an
277
+ ordering run with no --test-country prints a warning; the run still goes ahead.
278
+ --test-address1 <line> Street address. Default: 1600 Amphitheatre Pkwy.
279
+ --test-city <city> City. Default: Mountain View.
280
+ --test-province <code> State, province or region, as the province select's option value or label.
281
+ Default: CA. The checkout must show a province select for the country.
282
+ --test-postal <code> Postal code. Default: 94043.
252
283
  --legacy-api-test-order <off|accept|decline|both>
253
284
  Diagnostic-only direct Campaigns API order creation; bypasses deployed checkout.
254
285
  --api-key <key> Campaigns API key for legacy direct API diagnostics. Env QA_CAMPAIGNS_API_KEY is also recognized.
@@ -511,12 +542,25 @@ async function resolveQaInputs(args, {
511
542
  hiddenEagerMediaGate,
512
543
  packet,
513
544
  baseUrl: stringArg(args["base-url"]),
545
+ evidenceDebt: checkpointPreflight?.evidenceDebt || null,
514
546
  });
515
547
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
516
- const qaScope = applyQaBuildScope(topologies, {
548
+ const buildScope = applyQaBuildScope(topologies, {
517
549
  packet, report: checkpointPreflight?.runtimeReport,
518
550
  targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
519
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 };
520
564
  const brandContract = loadBrandContract(templateFamily);
521
565
  const localServeAnalytics = resolveLocalServeAnalytics({
522
566
  packet,
@@ -552,6 +596,7 @@ async function resolveQaInputs(args, {
552
596
  commerceStructureContract,
553
597
  topologies: qaScope.topologies,
554
598
  excludedPages: qaScope.excludedPages,
599
+ pageScope: pageScope.scope,
555
600
  checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
556
601
  // The report the checkpoint gates were evaluated on, and the target repo
557
602
  // whose default it may or may not be: what the printed remediation names.
@@ -624,13 +669,18 @@ function resolvePacketCheckpointPreflight(args, {
624
669
  waivers: report?.waivers,
625
670
  required: true,
626
671
  }),
627
- applyLocalPreviewToCheckpoint(
628
- evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
629
- { packet, report, baseUrl: stringArg(args["base-url"]) },
630
- ),
631
672
  ];
632
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));
633
682
  return {
683
+ evidenceDebt,
634
684
  packetPath,
635
685
  packet,
636
686
  specPath,
@@ -765,8 +815,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
765
815
  // yields "not_applicable" rather than blocking, so browser QA still runs the
766
816
  // residue/placeholder/demo gates. Test orders are not attempted (no policy).
767
817
  export function resolveQaInputsFromSite(args) {
768
- const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
769
- if (!baseUrl) {
818
+ const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
819
+ if (!inputBaseUrl) {
770
820
  throw refused("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
771
821
  }
772
822
  const templateFamily = stringArg(args.family);
@@ -778,6 +828,10 @@ export function resolveQaInputsFromSite(args) {
778
828
  if (!scope.ok) {
779
829
  throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
780
830
  }
831
+ // The built pages live under _site/<slug>/, so compose the slug onto a site
832
+ // origin the way packet QA does; a base URL already ending in the slug is
833
+ // left alone (#683).
834
+ const baseUrl = normalizeQaBaseUrl(inputBaseUrl, scope.slug);
781
835
  const brandContract = loadBrandContract(templateFamily);
782
836
  if (brandContract.status !== "loaded" || !brandContract.contract) {
783
837
  throw new Error(`Non-packet site QA requires a loadable template brand contract for --family "${templateFamily}" (got ${brandContract.status}).`);
@@ -800,8 +854,8 @@ export function resolveQaInputsFromSite(args) {
800
854
  polishGate,
801
855
  // Non-packet site QA has no Assembly Report, so no recorded waivers.
802
856
  qaWaivers: {},
803
- // Non-packet site QA: --base-url IS the served campaign root by this
804
- // mode's contract (no spec identity exists to compose from).
857
+ // Non-packet site QA: the served campaign root is --base-url with the
858
+ // built slug composed on (no spec identity exists to compose from).
805
859
  analyticsCaptureTarget: buildAnalyticsCaptureTarget({
806
860
  url: ensureUrlTrailingSlash(baseUrl),
807
861
  publicRouteSlug: scope.slug || null,
@@ -963,22 +1017,125 @@ function currentBuiltOutputFingerprint(packetPath) {
963
1017
  }
964
1018
  }
965
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
+
966
1122
  function resolvePolishGate({
967
1123
  packetPath,
968
1124
  report: reportOverride = undefined,
969
1125
  hiddenEagerMediaGate = undefined,
970
1126
  packet = null,
971
1127
  baseUrl = null,
1128
+ evidenceDebt = null,
972
1129
  }) {
973
1130
  const report = reportOverride === undefined
974
1131
  ? loadRuntimeArtifact(packetPath, "assembly-report.json")
975
1132
  : reportOverride;
976
- const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
1133
+ const gate = applyPageOnlyChangeToPolishGate(applyLocalPreviewToPolishGate(evaluatePolishGate({
977
1134
  report,
978
1135
  required: true,
979
1136
  hiddenEagerMediaGate,
980
1137
  currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
981
- }), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
1138
+ }), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl }), { debt: evidenceDebt, checkpointGate: hiddenEagerMediaGate });
982
1139
  gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
983
1140
  return gate;
984
1141
  }
@@ -1084,6 +1241,19 @@ function polishGateAssertion(gate) {
1084
1241
  },
1085
1242
  });
1086
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
+ }
1087
1257
  if (gate.status === CARRIED_FORWARD) {
1088
1258
  return assertion({
1089
1259
  id: gate.code,
@@ -1402,6 +1572,9 @@ function hiddenEagerMediaGateAssertion(gate) {
1402
1572
  if (summary.status === "waived") {
1403
1573
  return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
1404
1574
  }
1575
+ if (isPageOnlyCarry(summary)) {
1576
+ return assertion({ ...common, status: STATUS.SKIPPED, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
1577
+ }
1405
1578
  if (summary.status === CARRIED_FORWARD) {
1406
1579
  return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
1407
1580
  }
@@ -1607,7 +1780,7 @@ function serializeThrownValue(error) {
1607
1780
  // `derived.assembly_report_path` handling — so the printed remediations can
1608
1781
  // name it and a default-report campaign's output is unchanged.
1609
1782
  function reportPathField(resolved) {
1610
- const reportPath = explicitReportPath(resolved?.reportPath, resolved?.targetRepo);
1783
+ const reportPath = explicitReportPath(resolved?.reportPath, resolved?.targetRepo, packetRuntimeRelPath(resolved?.packet));
1611
1784
  return reportPath ? { report_path: reportPath } : {};
1612
1785
  }
1613
1786
 
@@ -1902,6 +2075,7 @@ function updateQaPolicy(args) {
1902
2075
  // #171: packet edits change what doctor would conclude; the retained
1903
2076
  // doctor sidecar (if any) now predates them.
1904
2077
  markDoctorSidecarStale(targetRepoFor(packetPath, packet), {
2078
+ path: campaignSidecarPaths(targetRepoFor(packetPath, packet), packetRuntimeRelPath(packet)).doctorOutPath,
1905
2079
  command: "qa policy set",
1906
2080
  reason: staleReason,
1907
2081
  });
@@ -2175,6 +2349,21 @@ function qaRunCurrency(args) {
2175
2349
  return value.toUpperCase();
2176
2350
  }
2177
2351
 
2352
+ // `qa run --currency <code>` pins the market the pages and orders run in, but
2353
+ // the order runner's shipping address comes from the --test-* flags and
2354
+ // defaults to a US one. A non-USD currency with no --test-country is the
2355
+ // likely mismatch, so it is named on stderr. Only the missing flag is judged:
2356
+ // a currency such as EUR has many markets, so an explicit --test-country is
2357
+ // taken as the operator's choice. The run is never refused.
2358
+ function warnCurrencyWithoutTestCountry(args, write = (message) => process.stderr.write(message)) {
2359
+ const currency = qaRunCurrency(args);
2360
+ if (!currency || currency === "USD") return false;
2361
+ if (String(args["test-order"] || "off").toLowerCase() === "off") return false;
2362
+ if (stringArg(args["test-country"])) return false;
2363
+ write(`[qa:test-order] --currency ${currency} with no --test-country: test orders use the default US shipping address. Pass --test-country, --test-address1, --test-city, --test-province and --test-postal for the ${currency} market, or ignore this if a US address is intended.\n`);
2364
+ return true;
2365
+ }
2366
+
2178
2367
  async function runParityQa(args) {
2179
2368
  // Checked here as well as on the budget itself: the budget is built after a
2180
2369
  // browser has launched, and a flag the operator typed wrong should cost them
@@ -2253,7 +2442,8 @@ async function runQa(args, options = {}) {
2253
2442
  // Fail-fast before anything resolves or launches. The authoritative check
2254
2443
  // lives on the creation budget itself, which every browser path builds.
2255
2444
  refuseBadOrderCreationLimit(args);
2256
- qaRunCurrency(args);
2445
+ refuseBadPageScope(args);
2446
+ warnCurrencyWithoutTestCountry(args);
2257
2447
  // Match dispatch: an active browser mode takes precedence over legacy API
2258
2448
  // diagnostics. Only the selected legacy path requires a cart and API mode.
2259
2449
  const browserMode = String(args["test-order"] || "off").toLowerCase();
@@ -2261,6 +2451,7 @@ async function runQa(args, options = {}) {
2261
2451
  if (browserMode === "off" && legacyMode !== "off") {
2262
2452
  refusing(() => legacyTestOrderInputs({ ...args, "test-order": legacyMode }));
2263
2453
  }
2454
+ vendorBeaconMode(args);
2264
2455
  const resolved = await resolveQaInputs(args);
2265
2456
  return runResolvedQa(args, resolved, options);
2266
2457
  }
@@ -2353,12 +2544,20 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2353
2544
  actual: "out_of_build_scope",
2354
2545
  evidence: { reason: "out_of_build_scope" },
2355
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
+ })),
2356
2554
  ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
2357
2555
  themeGateAssertion(gate),
2358
2556
  ];
2359
2557
  const contractAssertion = templateBrandContractAssertion(resolved);
2360
2558
  if (contractAssertion) assertions.push(contractAssertion);
2361
- 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 });
2362
2561
  const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
2363
2562
  const commercialIds = new Set(commercialPlanning.pages
2364
2563
  .filter((page) => page?.id !== undefined && page?.id !== null)
@@ -2403,6 +2602,12 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2403
2602
  proxyBase: resolved.proxyBase,
2404
2603
  });
2405
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 };
2406
2611
  const qcResults = [];
2407
2612
  if (args.browser === true) {
2408
2613
  const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
@@ -2438,6 +2643,7 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2438
2643
  }
2439
2644
 
2440
2645
  const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions, qcResults });
2646
+ if (vendorBeacons) assertions.push(vendorBeaconAssertion(vendorBeacons));
2441
2647
  const remainingAssertionBudget = Math.max(0, QA_VERDICT_ASSERTION_LIMIT - assertions.length);
2442
2648
  let commercialResult;
2443
2649
  try {
@@ -2446,6 +2652,7 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2446
2652
  captures: [...capturesByPageId.values()],
2447
2653
  planning: commercialPlanning,
2448
2654
  maxAssertions: remainingAssertionBudget,
2655
+ currency,
2449
2656
  });
2450
2657
  } catch (error) {
2451
2658
  commercialResult = {
@@ -2660,6 +2867,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2660
2867
  // `external:qa_verdict` whenever that is not the packet directory.
2661
2868
  const causeSummary = annotateQaAssertionCauses(assertions, {
2662
2869
  baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
2870
+ runtimeRelPath: resolved.packetPath ? packetRuntimeRelPath(resolved.packet) : undefined,
2663
2871
  targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
2664
2872
  mapId: resolved.mapId,
2665
2873
  localSpecId: resolved.localSpecId,
@@ -2775,6 +2983,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2775
2983
  // The QC rows the QA stage records; readers re-derive them from the qc.*
2776
2984
  // assertions of the verdict written above.
2777
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 } : {}),
2778
2989
  verdict,
2779
2990
  };
2780
2991
  }
@@ -2792,7 +3003,8 @@ function analyticsCaptureScope(resolved) {
2792
3003
  const rootUrl = rootCaptureUrl(resolved?.analyticsCaptureTarget?.url);
2793
3004
  const topologies = topologyList(resolved?.topologies);
2794
3005
  const partial = topologies.some((topology) => topology?.partial_build_scope)
2795
- || (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0);
3006
+ || (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0)
3007
+ || Boolean(resolved?.pageScope?.excluded?.length);
2796
3008
  const rootInScope = !rootUrl || !partial || topologies.some((topology) =>
2797
3009
  (Array.isArray(topology?.pages) ? topology.pages : []).some((page) =>
2798
3010
  typeof page?.url === "string" && isSameAnalyticsCapturePage(page.url, rootUrl)));
@@ -4260,6 +4472,7 @@ export const __qaNodeTestHooks = Object.freeze({
4260
4472
  templateBrandContractAssertion,
4261
4473
  deriveEntryUrls,
4262
4474
  qaRunCurrency,
4475
+ warnCurrencyWithoutTestCountry,
4263
4476
  withEntryCurrency,
4264
4477
  derivePageUrls,
4265
4478
  deriveTestedUrlsFromAssertions,
@@ -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) };
@@ -1,12 +1,14 @@
1
1
  import { localSpecIdentityFields } from "./spec-source-identity.mjs";
2
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { randomUUID } from "node:crypto";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
 
6
+ import { packetRuntimeRelPath } from "./campaign-workspace.mjs";
6
7
  import { deriveExceptions, validateVerdict } from "./qa-verdict.mjs";
7
8
 
8
9
  // The committed QA verdict sidecar: `.campaign-runtime/qa-verdict.json` beside
9
- // the Build Packet, the artifact campaigns-agent's readback projects. Full
10
+ // the Build Packet (under the packet's own runtime directory when it is one
11
+ // of several funnels in its project), the artifact campaigns-agent's readback projects. Full
10
12
  // verdicts under qa-output/ are gitignored because they carry live storefront
11
13
  // URLs, request evidence, and order references; the sidecar is committed to
12
14
  // merchant repos, so it is an allowlist PROJECTION of one verdict, never a
@@ -100,8 +102,18 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
100
102
  };
101
103
  }
102
104
 
103
- export function sidecarPathForPacket(packetPath) {
104
- return join(dirname(resolve(packetPath)), SIDECAR_RELATIVE_PATH);
105
+ // `packet` is read from `packetPath` when not given; an unreadable packet
106
+ // names the project-level location, which the writers refuse before using.
107
+ export function sidecarPathForPacket(packetPath, packet = readPacketOrNull(packetPath)) {
108
+ return join(dirname(resolve(packetPath)), packetRuntimeRelPath(packet), basename(SIDECAR_RELATIVE_PATH));
109
+ }
110
+
111
+ function readPacketOrNull(packetPath) {
112
+ try {
113
+ return JSON.parse(readFileSync(resolve(packetPath), "utf8"));
114
+ } catch {
115
+ return null;
116
+ }
105
117
  }
106
118
 
107
119
  const PACKET_SCHEMA = "campaign-runtime-build-packet/v0";