@nextcommerce/campaigns-os 1.37.2 → 1.41.2

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 (50) hide show
  1. package/AGENTS.md +115 -11
  2. package/CHANGELOG.md +556 -0
  3. package/README.md +43 -36
  4. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  5. package/contracts/effects.v1.json +4794 -0
  6. package/contracts/release-ledger.json +873 -0
  7. package/contracts/supported-surface.json +25 -5
  8. package/docs/activation-and-evidence.md +1 -1
  9. package/docs/build-packet.md +27 -16
  10. package/docs/demo-preview.md +3 -4
  11. package/docs/diagnostics.md +7 -4
  12. package/docs/effects.md +281 -0
  13. package/docs/gateway-login.md +113 -0
  14. package/docs/orientation-contract-reference.md +4 -1
  15. package/docs/progress-snapshots.md +3 -3
  16. package/docs/qa-and-test-orders.md +3 -3
  17. package/docs/readback.md +523 -0
  18. package/docs/runtime-readiness.md +1 -1
  19. package/docs/sdk-storage-compatibility.md +1 -1
  20. package/docs/skills-revision.md +364 -0
  21. package/docs/supported-surface.md +12 -4
  22. package/docs/versioning.md +8 -4
  23. package/package.json +8 -3
  24. package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
  25. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  26. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  27. package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
  28. package/skills/campaign-readback-classification/SKILL.md +230 -0
  29. package/skills/campaign-run-evidence/SKILL.md +140 -0
  30. package/skills/contribution-intake/SKILL.md +85 -0
  31. package/skills/next-campaigns-build/SKILL.md +33 -12
  32. package/skills/next-campaigns-os/SKILL.md +45 -21
  33. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  34. package/skills/next-campaigns-polish/SKILL.md +43 -17
  35. package/skills/next-campaigns-qa/SKILL.md +48 -24
  36. package/skills.json +39 -6
  37. package/src/admin-transport.mjs +123 -0
  38. package/src/cli.mjs +991 -200
  39. package/src/credential-store.mjs +183 -0
  40. package/src/deviation.mjs +3 -2
  41. package/src/diagnostic.mjs +4 -1
  42. package/src/gate-actions.mjs +2 -2
  43. package/src/install-mode.mjs +17 -9
  44. package/src/lifecycle.mjs +95 -0
  45. package/src/login.mjs +152 -0
  46. package/src/package-install-fixture.mjs +3 -2
  47. package/src/qa-node.mjs +56 -19
  48. package/src/qa-publish.mjs +108 -2
  49. package/src/readback.mjs +1936 -0
  50. package/src/remit.mjs +17 -3
package/src/qa-node.mjs CHANGED
@@ -60,6 +60,10 @@ import {
60
60
  import { resolveConsent } from "./consent.mjs";
61
61
  import { markDoctorSidecarStale } from "./doctor-sidecar.mjs";
62
62
  import { commitAssemblyReport } from "./stage-ledger.mjs";
63
+ // Tags a refusal raised before a qa handler runs, so the CLI's lifecycle
64
+ // persist step suppresses the journal append from one place. lifecycle.mjs
65
+ // imports nothing from this repository, so this cannot be circular.
66
+ import { refused, refusing } from "./lifecycle.mjs";
63
67
  import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
64
68
  import { loadParityFixture } from "./qa-parity-fixture.mjs";
65
69
  import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
@@ -99,7 +103,7 @@ Usage:
99
103
  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]
100
104
  campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
101
105
  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
102
- campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
106
+ 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
103
107
  campaigns-os qa resolve <map-id> --spec <campaign-spec.json> [--base-url <url>]
104
108
  campaigns-os qa run <map-id> --spec <campaign-spec.json> --base-url <url>
105
109
  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
@@ -146,6 +150,10 @@ Options:
146
150
  per the Run Record (already_published), an untrusted one, or one for another
147
151
  campaign. Exit 2 on a refusal, 1 on a failed post, 0 when published.
148
152
  --republish qa publish: post a verdict its Run Record already records as published.
153
+ --dry-run qa publish: run every refusal check and print what would be posted (endpoint,
154
+ verdict run id, payload bytes) without the POST. Nothing is sent and the Run
155
+ Record is not stamped; a refusal still exits 2, a clean dry run exits 0
156
+ (--json: dry_run, would_publish, would_post).
149
157
  --no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
150
158
  --auth-cookie <cookie> Cookie header for protected previews.
151
159
  --browser Run Playwright-rendered browser checks after static Node checks.
@@ -250,7 +258,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
250
258
  return result;
251
259
  }
252
260
  if (subcommand === "policy") {
253
- if (args._[2] !== "set") throw new Error(`Unknown qa policy command. Use: ${cmd("qa")} policy set --packet <campaign-runtime.build.json>`);
261
+ if (args._[2] !== "set") throw refused(`Unknown qa policy command. Use: ${cmd("qa")} policy set --packet <campaign-runtime.build.json>`);
254
262
  const result = updateQaPolicy(args);
255
263
  output(result, args);
256
264
  return result;
@@ -281,7 +289,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
281
289
  process.exitCode = result.ok ? 0 : 1;
282
290
  return result;
283
291
  }
284
- throw new Error(`Unknown qa command: ${subcommand}`);
292
+ throw refused(`Unknown qa command: ${subcommand}`);
285
293
  }
286
294
 
287
295
  // The package-owned browser install, runnable from any install mode. It is
@@ -338,7 +346,7 @@ async function resolveQaInputs(args, {
338
346
  loadCampaignEntry = loadPageKitCampaignEntry,
339
347
  } = {}) {
340
348
  if (args.packet && args.spec) {
341
- throw new Error("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
349
+ throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
342
350
  }
343
351
  // Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
344
352
  // that has only a built _site/ and a served URL — no Build Packet, no Map ID,
@@ -363,7 +371,15 @@ async function resolveQaInputs(args, {
363
371
  const mapId = stringArg(args["map-id"])
364
372
  || stringArg(args._[2])
365
373
  || stringArg(packet?.spec?.map_id);
366
- if (!mapId) throw new Error("QA requires a Map ID. Provide --packet or positional <map-id>.");
374
+ // A refusal to identify a campaign, not a failure to QA one: with no
375
+ // --packet, no --site/--built and no positional <map-id>, every read above
376
+ // was skipped and `qa run` has nothing to work on. Tagged on both paths, not
377
+ // just the empty one — the journal question is whether the CLI reached a
378
+ // handler's WORK, and a missing-identity refusal decides that before any
379
+ // spec is fetched, any browser launches and anything is written. (When a
380
+ // packet WAS named, it has been read by here; the packet read is a lookup
381
+ // for the same identity question, and one message cannot be two verdicts.)
382
+ if (!mapId) throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
367
383
 
368
384
  const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
369
385
  const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
@@ -1726,7 +1742,7 @@ const REMOVED_QA_POLICY_FLAGS = ["test-orders-allowed", "sandbox-test-card-confi
1726
1742
 
1727
1743
  function updateQaPolicy(args) {
1728
1744
  const packetPath = args.packet ? resolve(args.packet) : null;
1729
- if (!packetPath) throw new Error("qa policy set requires --packet <campaign-runtime.build.json>.");
1745
+ if (!packetPath) throw refused("qa policy set requires --packet <campaign-runtime.build.json>.");
1730
1746
  const packet = readJson(packetPath);
1731
1747
  packet.campaign ||= {};
1732
1748
  packet.deploy ||= {};
@@ -1735,12 +1751,15 @@ function updateQaPolicy(args) {
1735
1751
  // Test Orders have no permission flag. The two flags that once set one
1736
1752
  // were removed with their packet fields in supported surface 1.28.0; a
1737
1753
  // script still passing them gets told so instead of a silent no-op.
1754
+ // This check and the setOptional* value checks below run after reading only
1755
+ // the packet and before the packet write: refusals, so they journal nothing.
1738
1756
  const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
1739
1757
  if (removedFlags.length) {
1740
- throw new Error(`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.`);
1758
+ 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.`);
1741
1759
  }
1742
- // Validated with the other argv checks, before anything is written.
1743
- const orderPathDepth = parseOrderPathDepthFlag(args, { command: "qa policy set" });
1760
+ // Validated with the other argv checks, before anything is written: a
1761
+ // refusal at this call site, like the removed-flag check above.
1762
+ const orderPathDepth = refusing(() => parseOrderPathDepthFlag(args, { command: "qa policy set" }));
1744
1763
 
1745
1764
  const changed = [];
1746
1765
  setOptionalBoolean(packet.campaign, "allowed_domains_confirmed", args, "allowed-domains-confirmed", changed);
@@ -1810,21 +1829,24 @@ const WAIVABLE_QA_ASSERTIONS = Object.freeze(["analytics-correctness:purchase-fi
1810
1829
  // report.theme.waiver: { reason, waived_by, waived_at }.
1811
1830
  export function qaWaive(args) {
1812
1831
  const packetPath = args.packet ? resolve(args.packet) : null;
1813
- if (!packetPath) throw new Error("qa waive requires --packet <campaign-runtime.build.json>.");
1832
+ if (!packetPath) throw refused("qa waive requires --packet <campaign-runtime.build.json>.");
1814
1833
  const packet = readJson(packetPath);
1834
+ // The three flag checks run after reading only argv and the packet, ahead of
1835
+ // the report lookup: refusals, so they journal nothing. The missing report
1836
+ // below is a handler failure and is journaled.
1815
1837
  const assertionId = stringArg(args.assertion);
1816
1838
  if (!assertionId) {
1817
- throw new Error(`qa waive requires --assertion <id>. Waivable assertions: ${WAIVABLE_QA_ASSERTIONS.join(", ")}.`);
1839
+ throw refused(`qa waive requires --assertion <id>. Waivable assertions: ${WAIVABLE_QA_ASSERTIONS.join(", ")}.`);
1818
1840
  }
1819
1841
  if (!WAIVABLE_QA_ASSERTIONS.includes(assertionId)) {
1820
- throw new Error(
1842
+ throw refused(
1821
1843
  `qa waive does not accept --assertion "${assertionId}". The waiver lane is scoped to exactly: ${WAIVABLE_QA_ASSERTIONS.join(", ")}. `
1822
1844
  + "Extending the lane to another assertion is a design decision, not a flag.",
1823
1845
  );
1824
1846
  }
1825
1847
  const reason = stringArg(args.reason);
1826
1848
  if (!reason) {
1827
- throw new Error("qa waive requires --reason \"<why this failing blocker is acceptable for this campaign>\".");
1849
+ throw refused("qa waive requires --reason \"<why this failing blocker is acceptable for this campaign>\".");
1828
1850
  }
1829
1851
  const workspace = resolveCampaignWorkspace(packetPath, {
1830
1852
  packet,
@@ -1998,15 +2020,25 @@ function parityReplayEvidence(bundle) {
1998
2020
  return { order, capture, baselineCapture, orders: Array.isArray(bundle.orders) ? bundle.orders : [order] };
1999
2021
  }
2000
2022
 
2023
+ // The up-front half of the `--max-order-creations` check. The validator is
2024
+ // SHARED with the order-creation budget, which every browser path builds after
2025
+ // a browser has launched and orders may already have been created; a throw from
2026
+ // there is a handler failure and must still be journaled, so the refusal tag
2027
+ // cannot live inside the validator. It goes here via `refusing()`, at the two
2028
+ // entries that check the flag before anything is resolved or launched, where a
2029
+ // bad value has cost the operator nothing. Message and exit code are the
2030
+ // validator's own.
2031
+ const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
2032
+
2001
2033
  async function runParityQa(args) {
2002
2034
  // Checked here as well as on the budget itself: the budget is built after a
2003
2035
  // browser has launched, and a flag the operator typed wrong should cost them
2004
2036
  // nothing. The budget stays the authority — this is fail-fast, not the gate.
2005
- validatedOrderCreationLimit(args);
2037
+ refuseBadOrderCreationLimit(args);
2006
2038
  const fixturePath = stringArg(args.fixture);
2007
2039
  const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
2008
- if (!fixturePath) throw new Error("QA parity requires --fixture <parity-fixture.json>.");
2009
- if (!scenarioId) throw new Error("QA parity requires --scenario <scenario-id>.");
2040
+ if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
2041
+ if (!scenarioId) throw refused("QA parity requires --scenario <scenario-id>.");
2010
2042
 
2011
2043
  const fixture = await loadParityFixture(resolve(fixturePath));
2012
2044
  const scenario = resolveParityScenario(fixture, scenarioId);
@@ -2072,7 +2104,7 @@ async function runParityQa(args) {
2072
2104
  async function runQa(args, options = {}) {
2073
2105
  // Fail-fast before anything resolves or launches. The authoritative check
2074
2106
  // lives on the creation budget itself, which every browser path builds.
2075
- validatedOrderCreationLimit(args);
2107
+ refuseBadOrderCreationLimit(args);
2076
2108
  const resolved = await resolveQaInputs(args);
2077
2109
  return runResolvedQa(args, resolved, options);
2078
2110
  }
@@ -3232,16 +3264,21 @@ function stringArg(value) {
3232
3264
  return typeof value === "string" && value.trim() ? value.trim() : null;
3233
3265
  }
3234
3266
 
3267
+ // setOptionalBoolean and setOptionalString serve `qa policy set` only, ahead of
3268
+ // its packet write, so a bad value there is a refusal. booleanArg itself stays
3269
+ // untagged: it is shared with `qa run`'s analytics leg, which calls it mid-run,
3270
+ // where a throw is a handler failure — so the tag goes on this call site
3271
+ // (`refusing()`), not in the validator.
3235
3272
  function setOptionalBoolean(target, property, args, key, changed) {
3236
3273
  if (!(key in args)) return;
3237
- const value = booleanArg(args[key], key);
3274
+ const value = refusing(() => booleanArg(args[key], key));
3238
3275
  setIfChanged(target, property, value, changed);
3239
3276
  }
3240
3277
 
3241
3278
  function setOptionalString(target, property, args, key, changed) {
3242
3279
  if (!(key in args)) return;
3243
3280
  const value = stringArg(args[key]);
3244
- if (!value) throw new Error(`--${key} requires a value.`);
3281
+ if (!value) throw refused(`--${key} requires a value.`);
3245
3282
  setIfChanged(target, property, value, changed);
3246
3283
  }
3247
3284
 
@@ -20,10 +20,12 @@ import { existsSync, readFileSync } from "node:fs";
20
20
  import { basename, dirname, join, resolve } from "node:path";
21
21
 
22
22
  import { campaignSidecarPaths, targetRepoFor } from "./campaign-workspace.mjs";
23
+ import { refused } from "./lifecycle.mjs";
23
24
  import { SIDECAR_RELATIVE_PATH } from "./qa-sidecar.mjs";
24
25
  import { qaVerdictIdentityMatch } from "./qa-verdict-discovery.mjs";
25
- import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
26
+ import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISH_ENDPOINT, QA_VERDICT_PUBLISHERS } from "./qa-verdict-publish.mjs";
26
27
  import { validateVerdict } from "./qa-verdict.mjs";
28
+ import { assertFetchAvailable, assertSecureProxyBase, classifyRemitOutcome, describeRemitBaseKind } from "./remit.mjs";
27
29
  import { readRunRecordsForTarget, writeRunRecord } from "./run-record.mjs";
28
30
  import { identityMatches } from "./run-record-closeout.mjs";
29
31
  import { DEFAULT_PROXY_BASE } from "./spec-fetch.mjs";
@@ -32,6 +34,9 @@ import { singleLineFragment } from "./text-safety.mjs";
32
34
 
33
35
  export const QA_PUBLISH_STATUSES = Object.freeze({
34
36
  published: "published",
37
+ // --dry-run: every refusal check ran and none fired, so a real run would
38
+ // have posted. Nothing was sent, so this is neither published nor failed.
39
+ dry_run: "dry_run",
35
40
  refused: "refused",
36
41
  publish_failed: "publish_failed",
37
42
  });
@@ -55,6 +60,7 @@ export const QA_PUBLISH_REFUSALS = Object.freeze({
55
60
 
56
61
  export const QA_PUBLISH_EXIT_CODES = Object.freeze({
57
62
  [QA_PUBLISH_STATUSES.published]: 0,
63
+ [QA_PUBLISH_STATUSES.dry_run]: 0,
58
64
  [QA_PUBLISH_STATUSES.refused]: 2,
59
65
  [QA_PUBLISH_STATUSES.publish_failed]: 1,
60
66
  });
@@ -165,6 +171,25 @@ export function findRunRecordForVerdict({ records, packet, verdictRunId, verdict
165
171
  * the record stamping are assertable without a receiver or a filesystem.
166
172
  */
167
173
  export async function publishStoredVerdict(args, operations = {}) {
174
+ // `--dry-run` is a bare flag; `--dry-run true` must fail rather than quietly
175
+ // become a real POST.
176
+ // An up-front flag refusal, tagged like every other so the lifecycle
177
+ // journal records nothing for it (a plain Error here would be journaled as
178
+ // a handler failure).
179
+ if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
180
+ throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
181
+ }
182
+ const dryRun = args["dry-run"] === true;
183
+ const result = await attemptPublish(args, operations, dryRun);
184
+ if (!dryRun) return result;
185
+ // The envelope says what the real command would have done. A refusal is a
186
+ // refusal either way — same code, same exit — and so is a destination the
187
+ // transport gate turns down before any request; `would_publish` is true only
188
+ // when every check passed and the post is all that is left.
189
+ return { ...result, dry_run: true, would_publish: result.status === QA_PUBLISH_STATUSES.dry_run };
190
+ }
191
+
192
+ async function attemptPublish(args, operations, dryRun) {
168
193
  const ops = {
169
194
  readJsonFile: readJson,
170
195
  exists: existsSync,
@@ -268,6 +293,70 @@ export async function publishStoredVerdict(args, operations = {}) {
268
293
  );
269
294
  }
270
295
 
296
+ // Every refusal check above has run and none fired, so a real run would post
297
+ // now. Under --dry-run this is where it stops: nothing is sent, and the Run
298
+ // Record below is left exactly as it stands.
299
+ if (dryRun) {
300
+ // First, the checks the transport itself makes before it opens a socket.
301
+ // `publishQaVerdict` -> `remit` demands a fetch to send with
302
+ // (`assertFetchAvailable`) and then puts every destination through
303
+ // `assertSecureProxyBase`; a runtime with no global fetch, or a base that
304
+ // is not https (nor a loopback host for local testing), fails there,
305
+ // locally, with nothing sent: the real command reports publish_failed and
306
+ // exits 1. A preview may not approve a send the transport refuses, so both
307
+ // gates run here, in the transport's own order — the same functions, with
308
+ // the label and the (absent) credential the publish rail passes them — and
309
+ // the refusal is classified by the same classifier the real outcome goes
310
+ // through, so the message and the exit code are the real command's.
311
+ // `attempted: false` is the one difference: nothing was sent.
312
+ let destinationRefusal = null;
313
+ try {
314
+ assertFetchAvailable(globalThis.fetch);
315
+ assertSecureProxyBase(proxyBase, { label: "QA verdict publish", credential: null });
316
+ } catch (error) {
317
+ destinationRefusal = classifyRemitOutcome(error);
318
+ }
319
+ if (destinationRefusal) {
320
+ return {
321
+ ok: false,
322
+ action: "qa-publish",
323
+ status: QA_PUBLISH_STATUSES.publish_failed,
324
+ ...identity,
325
+ republished: republish && priorBlock?.state === "ok",
326
+ publish: {
327
+ attempted: false,
328
+ ok: destinationRefusal.ok,
329
+ error: destinationRefusal.error,
330
+ endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
331
+ result: destinationRefusal.result,
332
+ http_status: destinationRefusal.http_status,
333
+ base_kind: describeRemitBaseKind(proxyBase),
334
+ published_at: null,
335
+ },
336
+ dashboard_url: null,
337
+ run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
338
+ orders_placed: 0,
339
+ };
340
+ }
341
+ return {
342
+ ok: true,
343
+ action: "qa-publish",
344
+ status: QA_PUBLISH_STATUSES.dry_run,
345
+ ...identity,
346
+ republished: republish && priorBlock?.state === "ok",
347
+ would_post: {
348
+ endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
349
+ base_kind: describeRemitBaseKind(proxyBase),
350
+ verdict_run_id: verdictRunId,
351
+ payload_bytes: Buffer.byteLength(JSON.stringify(verdict), "utf8"),
352
+ },
353
+ publish: { attempted: false, ok: null, error: null, endpoint: QA_VERDICT_PUBLISH_ENDPOINT, result: null, http_status: null, base_kind: describeRemitBaseKind(proxyBase), published_at: null },
354
+ dashboard_url: qaPortalUrl(proxyBase, mapId, verdictRunId),
355
+ run_record: recordEntry ? { ...recordSummary(recordEntry, priorBlock), written: false, preserved: false } : null,
356
+ orders_placed: 0,
357
+ };
358
+ }
359
+
271
360
  const outcome = await ops.post(verdict, proxyBase);
272
361
  const publishedAt = ops.now();
273
362
  const block = qaVerdictPublishBlock(outcome, { verdictRunId, publisher: QA_VERDICT_PUBLISHERS.publish, publishedAt });
@@ -332,6 +421,21 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
332
421
  if (result.verdict_path) lines.push(`Verdict: ${result.verdict_path}${result.source_kind ? ` (${result.source_kind})` : ""}`);
333
422
  if (result.dashboard_url) lines.push(`QA portal: ${result.dashboard_url}`);
334
423
  lines.push("No order was placed and nothing was sent.");
424
+ if (result.dry_run) lines.push("Dry run (--dry-run): the real command refuses this the same way.");
425
+ return lines;
426
+ }
427
+ if (result.status === QA_PUBLISH_STATUSES.dry_run) {
428
+ const post = result.would_post || {};
429
+ lines.push("QA publish dry run (--dry-run): every refusal check passed and nothing was sent.");
430
+ lines.push(`Map ID: ${result.map_id}`);
431
+ lines.push(`Run ID: ${result.run_id}`);
432
+ lines.push(`Disposition: ${result.disposition}`);
433
+ lines.push(`Verdict: ${result.verdict_path} (${result.source_kind})`);
434
+ lines.push(`Would POST: ${post.endpoint} at ${result.proxy_base} [base: ${post.base_kind}] — verdict ${post.verdict_run_id}, ${post.payload_bytes} bytes`);
435
+ lines.push(result.run_record
436
+ ? `Run Record: ${result.run_record.run_id} would take the publish outcome (${result.run_record.path}); it was not touched.`
437
+ : "Run Record: none references this verdict under the packet's campaign, so a real publish would not be recorded on one.");
438
+ lines.push("Orders placed: 0 (qa publish never places orders).");
335
439
  return lines;
336
440
  }
337
441
  lines.push(result.status === QA_PUBLISH_STATUSES.published
@@ -356,7 +460,9 @@ export function qaPublishTextLines(result, { cmd = (verb) => `campaigns-os ${ver
356
460
  }
357
461
  lines.push("Orders placed: 0 (qa publish never places orders).");
358
462
  if (result.status === QA_PUBLISH_STATUSES.publish_failed) {
359
- lines.push(`Re-run ${cmd("qa")} publish with network access; the local verdict is untouched.`);
463
+ lines.push(result.dry_run
464
+ ? `Dry run (--dry-run): nothing was sent, and the real command fails here the same way — the destination is refused before any request. ${result.publish?.error || ""}`.trimEnd()
465
+ : `Re-run ${cmd("qa")} publish with network access; the local verdict is untouched.`);
360
466
  }
361
467
  return lines;
362
468
  }