@nextcommerce/campaigns-os 1.54.0 → 1.55.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 (53) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -12
  4. package/compatibility.json +1 -1
  5. package/contracts/release-ledger.json +441 -0
  6. package/contracts/supported-surface.json +2 -2
  7. package/docs/brand-theme-bridge.md +10 -0
  8. package/docs/build-packet.md +35 -19
  9. package/docs/campaigns-os-build-flow.md +1 -1
  10. package/docs/effects.md +2 -5
  11. package/docs/local-setup.md +1 -1
  12. package/docs/orientation-contract-reference.md +1 -1
  13. package/docs/qa-and-test-orders.md +55 -11
  14. package/docs/runtime-readiness.md +1 -1
  15. package/docs/skills-revision.md +10 -10
  16. package/docs/supported-surface.md +1 -1
  17. package/docs/workflow-findings-sidecar.md +57 -2
  18. package/package.json +3 -1
  19. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +3 -3
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +4 -4
  29. package/skills.json +10 -10
  30. package/src/adapter-decision-contract.mjs +26 -12
  31. package/src/brand-theme.mjs +44 -8
  32. package/src/campaign-identity.mjs +33 -4
  33. package/src/cli.mjs +36 -59
  34. package/src/consent.mjs +3 -2
  35. package/src/design-source-publication.mjs +1 -1
  36. package/src/deviation.mjs +2 -1
  37. package/src/doctor/checks.mjs +16 -6
  38. package/src/doctor/next-step.mjs +16 -6
  39. package/src/invocation.mjs +4 -3
  40. package/src/lifecycle.mjs +88 -6
  41. package/src/local-preview-policy.mjs +6 -0
  42. package/src/login.mjs +2 -1
  43. package/src/polish-browser.mjs +8 -2
  44. package/src/polish-node.mjs +6 -1
  45. package/src/polish-readability.mjs +2 -2
  46. package/src/qa-browser.mjs +521 -92
  47. package/src/qa-cart-entry.mjs +29 -6
  48. package/src/qa-node.mjs +144 -39
  49. package/src/qa-verdict-publish.mjs +2 -1
  50. package/src/run-record.mjs +46 -34
  51. package/src/sdk-markup.mjs +41 -1
  52. package/src/stage-ledger.mjs +14 -2
  53. package/src/stage-record.mjs +50 -13
@@ -70,12 +70,21 @@ export const CART_ENTRY_ROUTE_ATTRIBUTE = "data-next-url";
70
70
  // preventDefault() unconditionally, so its href never navigates, and it
71
71
  // resolves the attribute against the origin (campaign-cart url-utils), not the
72
72
  // document base. Without the attribute such a control adds to the cart and
73
- // stays put — no route. Anywhere else the SDK attribute has no navigation
73
+ // stays put — no route. A control matching one of `declaredRoutes` (each
74
+ // `{ selector, url }`, a route the page declares rather than one the control
75
+ // carries: the SDK checkout form's submit button and an upsell's accept /
76
+ // decline actions) goes to that url and nothing else, because the SDK
77
+ // handles the submission or the click itself, so an href="#" or the form's
78
+ // action never navigates. Anywhere else the SDK attribute has no navigation
74
79
  // semantics: an HTML anchor's own resolved href (native, so a <base href> is
75
80
  // honoured), then an href attribute, then `data-href`, then a wrapping form's
76
- // action. Only spellings with real navigation semantics count — an attribute
77
- // the SDK does not declare is not a route.
78
- export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAttribute, origin, baseHref }) {
81
+ // action — for that form's submit button only, and never inside a form
82
+ // matching `checkoutFormSelector`: the SDK checkout form, which the SDK
83
+ // submits itself, or, on a checkout or upsell / downsell page, every form,
84
+ // since there the only form-borne route is the one in `declaredRoutes`. Only
85
+ // spellings with real navigation semantics count — an attribute the SDK does
86
+ // not declare is not a route.
87
+ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAttribute, declaredRoutes = [], checkoutFormSelector = null, origin, baseHref }) {
79
88
  if (!element || typeof element.getAttribute !== "function") return null;
80
89
  if (typeof element.matches === "function" && element.matches(cartEntrySelector)) {
81
90
  const sdkRoute = String(element.getAttribute(cartEntryRouteAttribute) || "").trim();
@@ -88,12 +97,26 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
88
97
  return null;
89
98
  }
90
99
  }
100
+ const declared = typeof element.matches === "function"
101
+ ? (declaredRoutes || []).find((route) => route?.selector && element.matches(route.selector))
102
+ : null;
103
+ if (declared) {
104
+ try {
105
+ return new URL(declared.url, baseHref).href;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
91
110
  // An HTML anchor resolves its own href (an SVG <a> exposes an object, not a
92
111
  // string, and falls through to the attribute reading).
93
- if (String(element.tagName || "").toUpperCase() === "A" && typeof element.href === "string" && element.href) return element.href;
112
+ const tag = String(element.tagName || "").toUpperCase();
113
+ if (tag === "A" && typeof element.href === "string" && element.href) return element.href;
114
+ // The reflected type, so a <button> with no type attribute submits too.
115
+ const submits = (tag === "BUTTON" || tag === "INPUT") && String(element.type || "").toLowerCase() === "submit";
116
+ const insideRoutelessForm = Boolean(checkoutFormSelector) && typeof element.closest === "function" && Boolean(element.closest(checkoutFormSelector));
94
117
  const attr = element.getAttribute("href")
95
118
  || element.getAttribute("data-href")
96
- || (typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
119
+ || (submits && !insideRoutelessForm && typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
97
120
  if (!attr) return null;
98
121
  try {
99
122
  return new URL(attr, baseHref).href;
package/src/qa-node.mjs CHANGED
@@ -32,7 +32,7 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
32
32
  function cmd(verb, rest = "") {
33
33
  return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
34
34
  }
35
- import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
35
+ import { QA_RUN_CURRENCY, isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, UPSELL_ACTION_SPELLINGS, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
36
36
  import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
37
37
  import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
38
38
  import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
@@ -128,7 +128,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
128
128
  Usage:
129
129
  campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
130
130
  campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
131
- campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
131
+ campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
132
132
  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]
133
133
  campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
134
134
  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
@@ -189,6 +189,10 @@ Options:
189
189
  --no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
190
190
  served page's shipping and package refs against the live campaign; the verdict
191
191
  records the check not_run with reason disabled, never a pass.
192
+ --currency <code> qa run: enter each funnel's entry page with ?currency=<CODE> (three letters, e.g. GBP),
193
+ keeping its existing query. The flag takes one code per run (given twice, the
194
+ last one is used); the verdict records it as evidence.currency on the entry
195
+ page's http:<page> row. Not supported by qa parity or --legacy-api-test-order.
192
196
  --auth-cookie <cookie> Cookie header for protected previews.
193
197
  --browser Run Playwright-rendered browser checks after static Node checks.
194
198
  Requires one-time setup: campaigns-os qa install-browser
@@ -199,8 +203,9 @@ Options:
199
203
  --browser-timeout <ms> Browser navigation timeout. Default: 30000.
200
204
  --test-order <off|common|checkout|accept|decline|both|full|tiers[:checkout|common|full]|accept-decline[-accept...]>
201
205
  Create Playwright typed-card test orders through the tested checkout page.
202
- Test cards bypass the gateway and create no transactions, so no permission
203
- flags or packet policy are needed — just pick a mode. Default mode (bare
206
+ Test cards bypass the gateway and create no transactions, but each creates a real
207
+ store order record. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing orders.
208
+ No permission flag or packet policy is needed. Default mode (bare
204
209
  --test-order, or "common") runs every actual terminal path when they fit under
205
210
  the cap (--max-test-orders, default 6). Above the cap it runs checkout,
206
211
  first-offer accept/decline, and a deduplicated shortest real receipt path, then
@@ -1861,7 +1866,7 @@ function updateQaPolicy(args) {
1861
1866
  // the packet and before the packet write: refusals, so they journal nothing.
1862
1867
  const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
1863
1868
  if (removedFlags.length) {
1864
- throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders run from --test-order <mode> alone; there is no permission flag). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
1869
+ throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders use --test-order <mode>, with no permission flag; unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing these real store order records). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
1865
1870
  }
1866
1871
  // Validated with the other argv checks, before anything is written: a
1867
1872
  // refusal at this call site, like the removed-flag check above.
@@ -2136,11 +2141,34 @@ function parityReplayEvidence(bundle) {
2136
2141
  // validator's own.
2137
2142
  const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
2138
2143
 
2144
+ // `qa run --currency <code>`: one run proves one currency. The code is three
2145
+ // ASCII letters, upper-cased; anything else (no value, a list, a symbol) is
2146
+ // refused here, before anything is resolved, fetched or launched. So is a run
2147
+ // whose orders are legacy direct API orders: that request carries no
2148
+ // currency, so the order would be placed in the default one.
2149
+ function qaRunCurrency(args) {
2150
+ if (!Object.hasOwn(args, "currency")) return null;
2151
+ const value = args.currency;
2152
+ if (typeof value !== "string" || !/^[A-Za-z]{3}$/.test(value)) {
2153
+ const got = typeof value === "string" && value !== "" ? JSON.stringify(value) : "no value";
2154
+ throw refused(`--currency takes one three-letter currency code, such as GBP or EUR (got ${got}). Run qa run once per currency.`);
2155
+ }
2156
+ const browserMode = String(args["test-order"] || "off").toLowerCase();
2157
+ const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2158
+ if (browserMode === "off" && legacyMode !== "off") {
2159
+ throw refused("--currency is not supported with --legacy-api-test-order: the direct API order carries no currency and would be placed in the default one. Use --test-order <mode> for orders in that currency, or drop --currency.");
2160
+ }
2161
+ return value.toUpperCase();
2162
+ }
2163
+
2139
2164
  async function runParityQa(args) {
2140
2165
  // Checked here as well as on the budget itself: the budget is built after a
2141
2166
  // browser has launched, and a flag the operator typed wrong should cost them
2142
2167
  // nothing. The budget stays the authority — this is fail-fast, not the gate.
2143
2168
  refuseBadOrderCreationLimit(args);
2169
+ if (Object.hasOwn(args, "currency")) {
2170
+ throw refused("qa parity does not support --currency: the scenario's fixture sets the currency it checks. Use qa run --currency <code> to prove a currency.");
2171
+ }
2144
2172
  const fixturePath = stringArg(args.fixture);
2145
2173
  const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
2146
2174
  if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
@@ -2211,6 +2239,7 @@ async function runQa(args, options = {}) {
2211
2239
  // Fail-fast before anything resolves or launches. The authoritative check
2212
2240
  // lives on the creation budget itself, which every browser path builds.
2213
2241
  refuseBadOrderCreationLimit(args);
2242
+ qaRunCurrency(args);
2214
2243
  // Match dispatch: an active browser mode takes precedence over legacy API
2215
2244
  // diagnostics. Only the selected legacy path requires a cart and API mode.
2216
2245
  const browserMode = String(args["test-order"] || "off").toLowerCase();
@@ -2225,7 +2254,15 @@ async function runQa(args, options = {}) {
2225
2254
  // `runSessionActive` is threaded in from the CLI's single ambient-session read
2226
2255
  // rather than re-discovered here, so the closeout command this run prints and
2227
2256
  // the run_id the session will close under come from the same observation.
2228
- async function runResolvedQa(args, resolved, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2257
+ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2258
+ // --currency: every leg below (HTTP rows, browser loads, test orders) reads
2259
+ // the entry page's URL from these topologies, so it is tagged once here;
2260
+ // the validated code rides on args, under QA_RUN_CURRENCY, for the order
2261
+ // runner's own first load of each path.
2262
+ const currency = qaRunCurrency(args);
2263
+ if (currency) args = { ...args, [QA_RUN_CURRENCY]: currency };
2264
+ const entryCurrency = withEntryCurrency(resolvedInputs.topologies, currency);
2265
+ const resolved = currency ? { ...resolvedInputs, topologies: entryCurrency.topologies } : resolvedInputs;
2229
2266
  const startedAt = new Date().toISOString();
2230
2267
  const runId = generateRunId();
2231
2268
  const gate = resolved.themeGate;
@@ -2317,7 +2354,14 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
2317
2354
  const bindingScriptLoader = createBindingScriptLoader();
2318
2355
  const pages = resolved.topologies.flatMap(topology => topology.pages);
2319
2356
  const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
2320
- runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
2357
+ runPageChecks(page, args, {
2358
+ sourceLoader,
2359
+ bindingExpected,
2360
+ bindingScriptLoader,
2361
+ captureCommercial: commercialIds.has(String(page.page_id)),
2362
+ currency: entryCurrency.entryPages.has(page) ? currency : null,
2363
+ currencyNotApplied: entryCurrency.notApplied.get(page) || null,
2364
+ }));
2321
2365
  const livePages = new Map();
2322
2366
  for (const [index, page] of pages.entries()) {
2323
2367
  const pageResult = pageResults[index];
@@ -2706,7 +2750,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2706
2750
  // The classified outcome (attempted, ok, error, endpoint, result,
2707
2751
  // http_status, base_kind) and the Run Record block derived from it.
2708
2752
  publish: { ...publishOutcome, response: undefined },
2709
- qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString() }),
2753
+ qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString(), reason: publishDecision.reason }),
2710
2754
  publish_decision: { ...publishDecision, destination: publishDestination, consent_state: consent?.state ?? null },
2711
2755
  counts: countAssertions(verdict.assertions),
2712
2756
  theme_gate: themeGateSummary(resolved.themeGate),
@@ -2765,14 +2809,50 @@ const ENTRY_PAGE_TYPES = new Set([
2765
2809
  "review",
2766
2810
  ]);
2767
2811
 
2812
+ function entryPageOf(topology) {
2813
+ const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2814
+ if (!pages.length) return null;
2815
+ // #482: a partial build enters at its first in-scope page, which can be
2816
+ // an opted-in select or checkout rather than a later landing/presell.
2817
+ return topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2818
+ }
2819
+
2820
+ // Each funnel's entry page gets ?currency=<CODE> through the URL API, so the
2821
+ // query string and tracking params it already carries are kept. Returns the
2822
+ // topologies unchanged without a currency; `entryPages` holds the tagged pages
2823
+ // and `notApplied` the entry pages whose URL did not parse, with the reason
2824
+ // their http row records.
2825
+ function withEntryCurrency(topologies, currency) {
2826
+ const entryPages = new Set();
2827
+ const notApplied = new Map();
2828
+ if (!currency) return { topologies, entryPages, notApplied };
2829
+ const tagged = topologyList(topologies).map((topology) => {
2830
+ const entry = entryPageOf(topology);
2831
+ // No URL at all: the page gets the route-url row, not an http row.
2832
+ if (!entry?.url) return topology;
2833
+ let url = null;
2834
+ try {
2835
+ url = new URL(entry.url);
2836
+ } catch {
2837
+ url = null;
2838
+ }
2839
+ if (!url) {
2840
+ notApplied.set(entry, { currency, reason: "entry URL does not parse as an absolute URL" });
2841
+ return topology;
2842
+ }
2843
+ url.searchParams.set("currency", currency);
2844
+ const page = { ...entry, url: url.toString() };
2845
+ entryPages.add(page);
2846
+ return { ...topology, pages: topology.pages.map((item) => (item === entry ? page : item)) };
2847
+ });
2848
+ return { topologies: tagged, entryPages, notApplied };
2849
+ }
2850
+
2768
2851
  function deriveEntryUrls(topologies) {
2769
2852
  const entries = [];
2770
2853
  for (const topology of topologyList(topologies)) {
2771
- const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2772
- if (!pages.length) continue;
2773
- // #482: a partial build enters at its first in-scope page, which can be
2774
- // an opted-in select or checkout rather than a later landing/presell.
2775
- const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2854
+ const page = entryPageOf(topology);
2855
+ if (!page) continue;
2776
2856
  entries.push({
2777
2857
  funnel_id: topology.funnel_id || "default",
2778
2858
  funnel_name: topology.funnel_name || topology.funnel_id || "default",
@@ -2843,8 +2923,17 @@ async function runPageChecks(page, args, {
2843
2923
  captureCommercial = false,
2844
2924
  bindingExpected = { value: null },
2845
2925
  bindingScriptLoader = createBindingScriptLoader(),
2926
+ currency = null,
2927
+ currencyNotApplied = null,
2846
2928
  } = {}) {
2847
2929
  const assertions = [];
2930
+ // --currency is recorded on the entry page's HTTP row: the persisted
2931
+ // verdict drops every URL query, so the fetched URL cannot carry it. An
2932
+ // entry URL the currency could not be added to says so on the same row.
2933
+ const currencyEvidence = {
2934
+ ...(currency ? { currency } : {}),
2935
+ ...(currencyNotApplied ? { currency_not_applied: currencyNotApplied } : {}),
2936
+ };
2848
2937
  if (!page.url) {
2849
2938
  assertions.push(bindingAssertion(page, await observeBinding({ source: null, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
2850
2939
  assertions.push(assertion({
@@ -2880,7 +2969,7 @@ async function runPageChecks(page, args, {
2880
2969
  severity: SEVERITY.BLOCKER,
2881
2970
  expected: isHttpStatus ? "2xx HTTP response" : "fetchable bounded deployed page",
2882
2971
  actual: isHttpStatus ? `${source.status} ${source.status_text}`.trim() : null,
2883
- evidence: { transport_error: { code: source.error_code, message: source.error } },
2972
+ evidence: { transport_error: { code: source.error_code, message: source.error }, ...currencyEvidence },
2884
2973
  }));
2885
2974
  return {
2886
2975
  assertions,
@@ -2898,6 +2987,7 @@ async function runPageChecks(page, args, {
2898
2987
  status: STATUS.PASS,
2899
2988
  expected: "2xx HTTP response",
2900
2989
  actual: `${source.status} ${source.status_text}`.trim(),
2990
+ evidence: Object.keys(currencyEvidence).length ? currencyEvidence : undefined,
2901
2991
  }));
2902
2992
 
2903
2993
  const expectedMeta = page.expected_meta_tags || {};
@@ -3117,9 +3207,9 @@ async function maybeRunTestOrders(
3117
3207
  return { orders: [], receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
3118
3208
  }
3119
3209
  if (mode && mode !== "off") {
3120
- // Test Orders use global test cards: they bypass the payment gateway, create
3121
- // no transactions, and need no merchant setup or approval. `--test-order
3122
- // <mode>` is sufficient intent — no permission flags or packet policy gate.
3210
+ // Test Orders use global test cards: they bypass the payment gateway and create
3211
+ // no transactions, but each leaves a real store order record. Ask the operator
3212
+ // once in the first turn; `--test-order <mode>` has no permission flag or packet policy gate.
3123
3213
  const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics, spec: resolved.spec });
3124
3214
  assertions.push(...result.assertions);
3125
3215
  return {
@@ -3462,6 +3552,9 @@ function output(value, args) {
3462
3552
  printEntryUrlLines(value.entry_urls);
3463
3553
  console.log(`Run ID: ${value.run_id}`);
3464
3554
  console.log(`Disposition: ${value.verdict.disposition}`);
3555
+ if (value.verdict.disposition === "ready_with_exceptions") {
3556
+ console.log("This is a passing proof with exceptions. Report these exceptions to the operator; do not clear or waive them, or change markup just to make them pass.");
3557
+ }
3465
3558
  console.log(`Counts: ${Object.entries(value.counts).map(([status, count]) => `${count} ${status}`).join(", ")}`);
3466
3559
  printCauseLines(value.verdict);
3467
3560
  printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
@@ -3584,14 +3677,21 @@ function printCauseLines(verdict) {
3584
3677
  // One formatter, shared with the doctor report: a prior record that exists
3585
3678
  // but has no usable QA verdict is not the same state as no prior record, and
3586
3679
  // the two commands must not describe it differently.
3587
- for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
3680
+ if (verdict?.disposition !== "ready_with_exceptions") {
3681
+ for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
3682
+ }
3588
3683
  const exceptions = Array.isArray(verdict.exceptions) ? verdict.exceptions : [];
3589
3684
  if (!exceptions.length) return;
3590
3685
  console.log("Findings:");
3591
3686
  for (const exception of exceptions) {
3592
- const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
3593
3687
  const tag = formatCauseTag(exception);
3594
- console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
3688
+ if (verdict.disposition === "ready_with_exceptions") {
3689
+ const severity = exception.severity ? `; severity: ${exception.severity}` : "";
3690
+ console.log(`- id: ${exception.id || "(none)"}; page: ${exception.page || "(none)"} (${exception.status || "unknown"}${severity})${tag ? ` ${tag}` : ""}`);
3691
+ } else {
3692
+ const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
3693
+ console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
3694
+ }
3595
3695
  }
3596
3696
  }
3597
3697
 
@@ -3606,7 +3706,7 @@ export function themeGateLines(themeGate, packetPath = null, reportPath = null)
3606
3706
  for (const action of themeGate.required_actions || []) {
3607
3707
  lines.push(` - ${requiredActionText(action, { packetPath, reportPath })}`);
3608
3708
  }
3609
- lines.push("Or rerun with --theme-waive \"<reason>\" to record an ephemeral waiver for this run.");
3709
+ lines.push('Or rerun once with --theme-waive "<reason>"; this waiver applies only to that run. A recorded theme waive requires --waived-by "<named human>".');
3610
3710
  return lines;
3611
3711
  }
3612
3712
 
@@ -3644,8 +3744,8 @@ export function qaResolveNextProofLines(value) {
3644
3744
  `Next expected proof: ${qaRunCommandFromResolve(value)}`,
3645
3745
  `Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
3646
3746
  value.local_spec_id
3647
- ? "Typed-card test orders use global test cards (no transactions/no permission gate); local-spec QA stays in the repository."
3648
- : "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3747
+ ? "Typed-card test cards create no transactions but leave real store order records. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing them; there is no permission flag. Local-spec QA stays in the repository."
3748
+ : "Typed-card test cards create no transactions but leave real store order records. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing them; there is no permission flag. QA publishes to the portal by default.",
3649
3749
  ];
3650
3750
  }
3651
3751
 
@@ -4023,16 +4123,23 @@ function htmlIncludesRouteReference(html, expectedUrl) {
4023
4123
  return html.includes(expectedUrl) || html.includes(path);
4024
4124
  }
4025
4125
 
4026
- // A data-upsell-proxy button forwards its click to the SDK action inside the
4027
- // offer. With no such action, the route's URL can still sit in the page's meta
4028
- // tags, so the reference match would pass a control that does nothing.
4126
+ // A data-upsell-proxy button forwards its click to the SDK action of its own
4127
+ // spelling inside the offer. With no such action, the route's URL can still sit
4128
+ // in the page's meta tags, so the reference match would pass a control that
4129
+ // does nothing.
4029
4130
  function findDeadUpsellProxy(html, kind, page) {
4030
4131
  if (page.page_type !== "upsell" || kind === "next") return null;
4031
- const action = kind === "accept" ? "add" : "skip";
4032
- if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) return null;
4033
- const target = new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
4034
- if (upsellOfferElements(html).some((offer) => target.test(offer))) return null;
4035
- return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
4132
+ for (const action of UPSELL_ACTION_SPELLINGS[kind]) {
4133
+ if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) continue;
4134
+ const target = upsellActionPattern(action);
4135
+ if (upsellOfferElements(html).some((offer) => target.test(offer))) continue;
4136
+ return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
4137
+ }
4138
+ return null;
4139
+ }
4140
+
4141
+ function upsellActionPattern(action) {
4142
+ return new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
4036
4143
  }
4037
4144
 
4038
4145
  // The markup of each [data-next-upsell="offer"] element, read to its closing
@@ -4061,14 +4168,9 @@ function upsellOfferElements(html) {
4061
4168
  }
4062
4169
 
4063
4170
  function findSdkRouteAction(html, kind, page) {
4064
- if (page.page_type !== "upsell") return null;
4065
- if (kind === "accept" && /\bdata-next-upsell-action\s*=\s*["']add["']/i.test(html)) {
4066
- return 'SDK upsell accept control: data-next-upsell-action="add"';
4067
- }
4068
- if (kind === "decline" && /\bdata-next-upsell-action\s*=\s*["']skip["']/i.test(html)) {
4069
- return 'SDK upsell decline control: data-next-upsell-action="skip"';
4070
- }
4071
- return null;
4171
+ if (page.page_type !== "upsell" || kind === "next") return null;
4172
+ const action = UPSELL_ACTION_SPELLINGS[kind].find((spelling) => upsellActionPattern(spelling).test(html));
4173
+ return action ? `SDK upsell ${kind} control: data-next-upsell-action="${action}"` : null;
4072
4174
  }
4073
4175
 
4074
4176
  function decodeHtml(value) {
@@ -4128,6 +4230,7 @@ function extractApiError(raw) {
4128
4230
  }
4129
4231
 
4130
4232
  export const __qaNodeTestHooks = Object.freeze({
4233
+ output,
4131
4234
  extractTopologies,
4132
4235
  validatedOrderCreationLimit,
4133
4236
  resolveQaInputs,
@@ -4152,6 +4255,8 @@ export const __qaNodeTestHooks = Object.freeze({
4152
4255
  themeGateSummary,
4153
4256
  templateBrandContractAssertion,
4154
4257
  deriveEntryUrls,
4258
+ qaRunCurrency,
4259
+ withEntryCurrency,
4155
4260
  derivePageUrls,
4156
4261
  deriveTestedUrlsFromAssertions,
4157
4262
  resolvePayload,
@@ -83,7 +83,7 @@ export function skippedQaVerdictPublish() {
83
83
  * remit (`ok` when the receiver's answer counts as stored, `failed` when it
84
84
  * refused or never answered, `skipped` when nothing was sent).
85
85
  */
86
- export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null }) {
86
+ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null, reason = null }) {
87
87
  const attempted = Boolean(outcome?.attempted);
88
88
  const ok = attempted ? outcome.ok === true : null;
89
89
  return {
@@ -94,6 +94,7 @@ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publis
94
94
  error: attempted && typeof outcome.error === "string" ? outcome.error : null,
95
95
  endpoint: attempted ? outcome.endpoint ?? QA_VERDICT_PUBLISH_ENDPOINT : null,
96
96
  state: attempted ? (ok ? "ok" : "failed") : "skipped",
97
+ reason: reason ?? outcome?.reason ?? null,
97
98
  result: attempted ? outcome.result ?? null : null,
98
99
  base_kind: attempted ? outcome.base_kind ?? null : null,
99
100
  published_at: attempted && ok ? publishedAt : null,
@@ -14,6 +14,8 @@ import { randomBytes } from "node:crypto";
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join, resolve } from "node:path";
16
16
  import { ADAPTER_DECISION_STRATEGY_FIELDS } from "./adapter-decision-contract.mjs";
17
+ import { currentBuildFingerprint } from "./polish-gate.mjs";
18
+ import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
17
19
 
18
20
  export const RUN_RECORD_SCHEMA = "campaigns-os-run-record/v0";
19
21
  export const RUN_RECORDS_DIR_REL_PATH = ".campaign-runtime/run-records";
@@ -60,6 +62,14 @@ export const RUN_RECORD_REMIT_BASE_KINDS = ["canonical", "loopback", "proxy"];
60
62
  // to refuse re-posting a verdict the portal already holds.
61
63
  export const RUN_RECORD_QA_VERDICT_PUBLISH_STATES = ["skipped", "ok", "failed"];
62
64
  export const RUN_RECORD_QA_VERDICT_PUBLISHERS = ["qa run", "qa publish"];
65
+ export const RUN_RECORD_QA_VERDICT_PUBLISH_REASONS = ["loopback_base_url", "portal_managed_default", "consent_off", "flag_opt_out", "flag_opt_in", "default", "local_spec"];
66
+ export const RUN_RECORD_CLOSERS = ["run_end", "qa_auto_end", "stale_sweep", "manual"];
67
+ const RUN_RECORD_FIELDS = new Set([
68
+ "schema_version", "run_id", "package_version", "surface_version", "toolkit_commit", "command", "argv_shape", "created_at",
69
+ "consent_state", "consent_source", "remit_attempted", "remit_ok", "remit_error", "remit_endpoint", "remit_state",
70
+ "remit_result", "remit_base_kind", "qa_verdict_publish", "closed_by", "identity", "artifacts", "observations",
71
+ "surfaces", "primary_surface", "surface_confidence", "lifecycle",
72
+ ]);
63
73
 
64
74
  // Required core. Strict here; permissive about optional sub-structures (the
65
75
  // validator checks shapes, not nested artifact bodies — those are referenced
@@ -105,6 +115,12 @@ export function validateRunRecord(record) {
105
115
  if (record.schema_version != null && record.schema_version !== RUN_RECORD_SCHEMA) {
106
116
  add("record.schema_version", `Expected schema_version "${RUN_RECORD_SCHEMA}".`);
107
117
  }
118
+ for (const key of Object.keys(record)) {
119
+ if (!RUN_RECORD_FIELDS.has(key)) add(`record.${key}`, `unknown Run Record field "${key}".`);
120
+ }
121
+ if (record.closed_by !== undefined && !RUN_RECORD_CLOSERS.includes(record.closed_by)) {
122
+ add("record.closed_by", `closed_by must be one of: ${RUN_RECORD_CLOSERS.join(", ")}.`);
123
+ }
108
124
 
109
125
  if (record.argv_shape == null || !isStringArray(record.argv_shape)) {
110
126
  add("record.argv_shape", "argv_shape is required and must be an array of strings (flag names, never values).");
@@ -215,25 +231,16 @@ export function validateRunRecord(record) {
215
231
  const q = obs.qa;
216
232
  if (typeof q !== "object" || Array.isArray(q)) {
217
233
  add("record.observations.qa", "qa must be an object.");
218
- } else if (q.gap_classes != null && !isStringArray(q.gap_classes)) {
219
- add("record.observations.qa.gap_classes", "gap_classes must be an array of strings.");
220
- }
221
- }
222
- }
223
- }
224
-
225
- if (record.agent_usage != null) {
226
- const usage = record.agent_usage;
227
- if (typeof usage !== "object" || Array.isArray(usage)) {
228
- add("record.agent_usage", "agent_usage must be an object when present.");
229
- } else {
230
- for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
231
- if (usage[field] != null && (!Number.isInteger(usage[field]) || usage[field] < 0)) {
232
- add(`record.agent_usage.${field}`, `${field} must be a non-negative integer when present.`);
234
+ } else {
235
+ if (q.gap_classes != null && !isStringArray(q.gap_classes)) add("record.observations.qa.gap_classes", "gap_classes must be an array of strings.");
236
+ if (q.verdict_run_id != null && !isNonEmptyString(q.verdict_run_id)) add("record.observations.qa.verdict_run_id", "verdict_run_id must be a non-empty string or null.");
237
+ if (q.build_fingerprint != null && !isNonEmptyString(q.build_fingerprint)) add("record.observations.qa.build_fingerprint", "build_fingerprint must be a non-empty string or null.");
238
+ if (q.stale != null && typeof q.stale !== "boolean") add("record.observations.qa.stale", "stale must be a boolean or null.");
239
+ for (const key of Object.keys(q)) {
240
+ if (!["disposition", "gap_classes", "verdict_run_id", "build_fingerprint", "stale"].includes(key)) add(`record.observations.qa.${key}`, `unknown qa observation field "${key}".`);
241
+ }
233
242
  }
234
243
  }
235
- if (usage.model != null && typeof usage.model !== "string") add("record.agent_usage.model", "model must be a string when present.");
236
- if (usage.source != null && typeof usage.source !== "string") add("record.agent_usage.source", "source must be a string when present.");
237
244
  }
238
245
  }
239
246
 
@@ -301,7 +308,10 @@ export function validateQaVerdictPublish(block) {
301
308
  if (block.published_at != null && !isNonEmptyString(block.published_at)) {
302
309
  add("record.qa_verdict_publish.published_at", "published_at must be a non-empty string or null.");
303
310
  }
304
- const allowed = new Set(["verdict_run_id", "publisher", "attempted", "ok", "error", "endpoint", "state", "result", "base_kind", "published_at"]);
311
+ if (block.reason != null && !RUN_RECORD_QA_VERDICT_PUBLISH_REASONS.includes(block.reason)) {
312
+ add("record.qa_verdict_publish.reason", `reason must be one of: ${RUN_RECORD_QA_VERDICT_PUBLISH_REASONS.join(", ")} (or null).`);
313
+ }
314
+ const allowed = new Set(["verdict_run_id", "publisher", "attempted", "ok", "error", "endpoint", "state", "result", "base_kind", "published_at", "reason"]);
305
315
  for (const key of Object.keys(block)) {
306
316
  if (!allowed.has(key)) add(`record.qa_verdict_publish.${key}`, `unknown qa_verdict_publish field "${key}".`);
307
317
  }
@@ -324,6 +334,9 @@ export function validateRunRecordLifecycle(lc) {
324
334
  if (lc.duration_ms != null && typeof lc.duration_ms !== "number") add("record.lifecycle.duration_ms", "duration_ms must be a number or null.");
325
335
  if (lc.wall_clock_duration_ms != null && typeof lc.wall_clock_duration_ms !== "number") add("record.lifecycle.wall_clock_duration_ms", "wall_clock_duration_ms must be a number or null.");
326
336
  if (lc.repair_loop_count != null && !Number.isInteger(lc.repair_loop_count)) add("record.lifecycle.repair_loop_count", "repair_loop_count must be an integer or null.");
337
+ for (const field of ["needs_input_count", "failure_count"]) {
338
+ if (lc[field] !== undefined && (!Number.isInteger(lc[field]) || lc[field] < 0)) add(`record.lifecycle.${field}`, `${field} must be a non-negative integer.`);
339
+ }
327
340
  if (lc.stages != null) {
328
341
  if (!Array.isArray(lc.stages)) {
329
342
  add("record.lifecycle.stages", "stages must be an array when present.");
@@ -334,6 +347,8 @@ export function validateRunRecordLifecycle(lc) {
334
347
  } else {
335
348
  if (stage.duration_ms != null && typeof stage.duration_ms !== "number") add(`record.lifecycle.stages[${index}].duration_ms`, "stage duration_ms must be a number or null.");
336
349
  if (stage.exit_status != null && !Number.isInteger(stage.exit_status)) add(`record.lifecycle.stages[${index}].exit_status`, "stage exit_status must be an integer or null.");
350
+ if (stage.wait_ms != null && (!Number.isInteger(stage.wait_ms) || stage.wait_ms < 0)) add(`record.lifecycle.stages[${index}].wait_ms`, "stage wait_ms must be a non-negative integer or null.");
351
+ if (stage.finding_codes !== undefined && (!isStringArray(stage.finding_codes) || stage.finding_codes.length > 5 || new Set(stage.finding_codes).size !== stage.finding_codes.length || !Number.isInteger(stage.exit_status) || stage.exit_status === 0)) add(`record.lifecycle.stages[${index}].finding_codes`, "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
337
352
  }
338
353
  });
339
354
  }
@@ -395,8 +410,14 @@ function extractSpecValidationRuleIds(doctor) {
395
410
  return [...new Set(ids)];
396
411
  }
397
412
 
398
- function extractQaObservations(verdict) {
413
+ function extractQaObservations(verdict, report) {
399
414
  if (!verdict || typeof verdict !== "object") return null;
415
+ const verdictRunId = isNonEmptyString(verdict.run_id) ? verdict.run_id : null;
416
+ // The verdict owns the disposition. The report owns QA's build stamp, so
417
+ // only bind the two when its QA stage names this exact verdict.
418
+ const sameVerdict = verdictRunId !== null && report?.stages?.qa?.verdict_run_id === verdictRunId;
419
+ const recorded = sameVerdict ? qaRecordedBuildFingerprint(report) : null;
420
+ const current = currentBuildFingerprint(report);
400
421
  const families = new Set();
401
422
  for (const exception of Array.isArray(verdict.exceptions) ? verdict.exceptions : []) {
402
423
  if (typeof exception?.family === "string") families.add(exception.family);
@@ -404,6 +425,9 @@ function extractQaObservations(verdict) {
404
425
  return {
405
426
  disposition: typeof verdict.disposition === "string" ? verdict.disposition : null,
406
427
  gap_classes: [...families],
428
+ verdict_run_id: verdictRunId,
429
+ build_fingerprint: recorded,
430
+ stale: recorded && current ? !qaRecordedForCurrentBuild(report, current) : null,
407
431
  };
408
432
  }
409
433
 
@@ -465,17 +489,6 @@ function normalizeRemitState(remit) {
465
489
  return "skipped";
466
490
  }
467
491
 
468
- function normalizeAgentUsage(usage) {
469
- if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
470
- const out = {};
471
- for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
472
- if (Number.isInteger(usage[field]) && usage[field] >= 0) out[field] = usage[field];
473
- }
474
- if (isNonEmptyString(usage.model)) out.model = usage.model.trim();
475
- if (isNonEmptyString(usage.source)) out.source = usage.source.trim();
476
- return Object.keys(out).length ? out : null;
477
- }
478
-
479
492
  const PRIMARY_SURFACE_PRIORITY = ["platform", "template", "design-source", "spec-rule", "cli", "skill", "docs"];
480
493
 
481
494
  const ADAPTER_DECISION_SURFACE_BY_FIELD = {
@@ -667,7 +680,7 @@ export function assembleRunRecord({
667
680
  primarySurface = null,
668
681
  surfaceConfidence = null,
669
682
  lifecycle = null,
670
- agentUsage = null,
683
+ closedBy = null,
671
684
  qaVerdictPublish = null,
672
685
  now = new Date(),
673
686
  } = {}) {
@@ -679,7 +692,7 @@ export function assembleRunRecord({
679
692
  }
680
693
  const adapter = extractAdapterDecisions(selectAdapterDecisions({ packet, report, context }));
681
694
  if (adapter) observations.adapter_decisions = adapter;
682
- const qaObs = extractQaObservations(qaVerdict);
695
+ const qaObs = extractQaObservations(qaVerdict, report);
683
696
  if (qaObs) observations.qa = qaObs;
684
697
  observations.finding_ids = selectRunFindingIds(journal, runId);
685
698
  const malformedJournalLines = Array.isArray(journal?.malformed)
@@ -732,8 +745,7 @@ export function assembleRunRecord({
732
745
  }
733
746
  if (surfaceConfidence) record.surface_confidence = surfaceConfidence;
734
747
  if (lifecycle && typeof lifecycle === "object" && !Array.isArray(lifecycle)) record.lifecycle = lifecycle;
735
- const normalizedUsage = normalizeAgentUsage(agentUsage);
736
- if (normalizedUsage) record.agent_usage = normalizedUsage;
748
+ if (RUN_RECORD_CLOSERS.includes(closedBy)) record.closed_by = closedBy;
737
749
  // Present only when a publish outcome is known: an absent block reads as
738
750
  // "nothing recorded", which is what every record before this field says.
739
751
  if (qaVerdictPublish && typeof qaVerdictPublish === "object" && !Array.isArray(qaVerdictPublish)) record.qa_verdict_publish = qaVerdictPublish;