@nextcommerce/campaigns-os 1.37.3 → 1.43.1

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 (76) hide show
  1. package/AGENTS.md +114 -10
  2. package/CHANGELOG.md +708 -0
  3. package/README.md +44 -31
  4. package/agents/claude/CLAUDE.md +5 -1
  5. package/campaign-spec/dist/types.d.ts +2 -0
  6. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  7. package/contracts/effects.v1.json +4887 -0
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1541 -0
  10. package/contracts/supported-surface.json +33 -12
  11. package/docs/build-packet.md +83 -22
  12. package/docs/campaigns-os-build-flow.md +2 -2
  13. package/docs/demo-preview.md +1 -1
  14. package/docs/diagnostics.md +7 -4
  15. package/docs/effects.md +350 -0
  16. package/docs/gateway-login.md +113 -0
  17. package/docs/local-setup.md +51 -0
  18. package/docs/migration-sidecar-bundle.md +6 -1
  19. package/docs/orientation-contract-reference.md +4 -1
  20. package/docs/progress-snapshots.md +9 -3
  21. package/docs/qa-and-test-orders.md +29 -13
  22. package/docs/readback.md +523 -0
  23. package/docs/runtime-readiness.md +1 -1
  24. package/docs/sdk-storage-compatibility.md +1 -1
  25. package/docs/skills-revision.md +364 -0
  26. package/docs/supported-surface.md +11 -3
  27. package/docs/versioning.md +8 -4
  28. package/package.json +10 -4
  29. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  30. package/schemas/campaign-runtime-build-packet.v0.schema.json +11 -1
  31. package/schemas/campaign-spec.v4.schema.json +4 -0
  32. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  33. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  34. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  35. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  36. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  37. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  38. package/skills/campaign-lifecycle-orientation/SKILL.md +179 -0
  39. package/skills/campaign-readback-classification/SKILL.md +230 -0
  40. package/skills/campaign-run-evidence/SKILL.md +142 -0
  41. package/skills/contribution-intake/SKILL.md +85 -0
  42. package/skills/next-campaigns-build/SKILL.md +33 -12
  43. package/skills/next-campaigns-os/SKILL.md +59 -22
  44. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  45. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  46. package/skills/next-campaigns-polish/SKILL.md +43 -17
  47. package/skills/next-campaigns-qa/SKILL.md +53 -28
  48. package/skills.json +40 -7
  49. package/src/admin-transport.mjs +123 -0
  50. package/src/cli.mjs +1178 -270
  51. package/src/credential-store.mjs +183 -0
  52. package/src/deviation.mjs +3 -2
  53. package/src/diagnostic.mjs +4 -1
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/gate-actions.mjs +2 -2
  56. package/src/install-mode.mjs +17 -9
  57. package/src/lifecycle.mjs +96 -0
  58. package/src/login.mjs +152 -0
  59. package/src/package-install-fixture.mjs +3 -2
  60. package/src/polish-node.mjs +5 -2
  61. package/src/progress-node.mjs +3 -2
  62. package/src/progress.mjs +5 -3
  63. package/src/qa-node.mjs +105 -36
  64. package/src/qa-publish.mjs +112 -2
  65. package/src/qa-sidecar.mjs +2 -0
  66. package/src/qa-verdict-discovery.mjs +11 -0
  67. package/src/qa-verdict-publish.mjs +1 -0
  68. package/src/qa-verdict.mjs +8 -1
  69. package/src/readback.mjs +1937 -0
  70. package/src/remit.mjs +17 -3
  71. package/src/run-record-closeout.mjs +3 -4
  72. package/src/run-record.mjs +4 -0
  73. package/src/sidecar-bundle.mjs +21 -0
  74. package/src/spec-source-identity.mjs +44 -0
  75. package/src/stage-ledger.mjs +4 -1
  76. package/src/tooling-setup.mjs +160 -0
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -330,8 +331,8 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
330
331
  const slug = captureCampaignSlug(packet, report);
331
332
  const packetMapId = nonemptyString(packet?.spec?.map_id);
332
333
  const reportMapId = nonemptyString(report?.identity?.map_id);
333
- if (!packetMapId || packetMapId !== reportMapId) {
334
- throw new Error("polish capture requires matching packet and Assembly Report map identities.");
334
+ if (!campaignIdentitiesMatch(packet?.spec, report?.identity)) {
335
+ throw new Error("polish capture requires matching packet and Assembly Report campaign identities.");
335
336
  }
336
337
  const buildFingerprint = currentBuildFingerprint(report);
337
338
  if (!buildFingerprint) throw new Error("polish capture requires a strict current Assembly Report build fingerprint.");
@@ -360,6 +361,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
360
361
  resolved_path: resolvedPacketPath,
361
362
  resolved_target_repo: resolvedTargetRepo,
362
363
  map_id: packetMapId,
364
+ ...localSpecIdentityFields(packet.spec),
363
365
  campaign_slug: slug,
364
366
  route_root: nonemptyString(packet?.campaign?.route_root),
365
367
  target_repo: nonemptyString(packet?.assembly?.target_repo),
@@ -368,6 +370,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
368
370
  run_id: runId,
369
371
  identity: {
370
372
  map_id: reportMapId,
373
+ ...localSpecIdentityFields(report.identity),
371
374
  public_route_slug: nonemptyString(report?.identity?.public_route_slug),
372
375
  spec_hash: nonemptyString(report?.identity?.spec_hash),
373
376
  },
@@ -1,3 +1,4 @@
1
+ import { campaignSpecIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  // Best-effort producer adapter. Sanitized immutable bytes are durable before delivery.
2
3
  import {randomBytes,createHash} from 'node:crypto';
3
4
  import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync,lstatSync} from 'node:fs';
@@ -46,7 +47,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
46
47
  const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
47
48
  const build=hash(doctor?.derived?.build_output_fingerprint?.value);
48
49
  const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
49
- const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&mapId&&localMapId===mapId&&report?.identity?.map_id===mapId&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
50
+ const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&campaignIdentitiesMatch(packet?.spec,campaignSpecIdentity(spec))&&campaignIdentitiesMatch(packet?.spec,report?.identity)&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
50
51
  const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
51
52
  const verdict=qaResult?.verdict;
52
53
  const qa=verdict?{
@@ -60,7 +61,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
60
61
  const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
61
62
  return {
62
63
  schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
63
- identity:{map_id:mapId,map_revision_hash:contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null),map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
64
+ identity:{map_id:mapId,...localSpecIdentityFields(packet?.spec),map_revision_hash:mapId?(contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null)):null,map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
64
65
  stages:PROGRESS_STAGES.map(stage=>{
65
66
  const status=reportBound?accepted(report?.stages?.[stage]?.status,PROGRESS_STAGE_STATUSES):'unknown';
66
67
  const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
package/src/progress.mjs CHANGED
@@ -22,7 +22,7 @@ const str = pattern => ({type:'string', pattern});
22
22
  const hash = {type:['string','null'], pattern:'^sha256:[0-9a-f]{64}$'};
23
23
  const opaque = {type:['string','null'], pattern:'^[A-Za-z0-9_-]{1,64}$'};
24
24
  const enumeration = values => ({enum:values});
25
- const object = properties => ({type:'object', additionalProperties:false, required:Object.keys(properties), properties});
25
+ const object = (properties, optional=[]) => ({type:'object', additionalProperties:false, required:Object.keys(properties).filter(key=>!optional.includes(key)), properties});
26
26
  export const PROGRESS_SNAPSHOT_SCHEMA = {
27
27
  $schema:'https://json-schema.org/draft/2020-12/schema',
28
28
  $id:'https://nextcommerce.com/schemas/campaigns-os-progress-snapshot.v0.schema.json',
@@ -32,10 +32,10 @@ export const PROGRESS_SNAPSHOT_SCHEMA = {
32
32
  stream_id:str('^progress_[0-9a-f]{32}$'), sequence:{type:'integer',minimum:1,maximum:2147483647},
33
33
  previous_snapshot_id:hash, observed_at:str('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'),
34
34
  package_version:str('^\\d{1,6}\\.\\d{1,6}\\.\\d{1,6}$'), producer:enumeration(['next','qa']),
35
- identity:object({map_id:opaque,map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
35
+ identity:object({map_id:opaque,local_spec_id:str('^[A-Za-z0-9_-]{1,64}$'),map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
36
36
  saved_revision_alignment:enumeration(['aligned','unconfirmed']),local_spec_material_hash:hash,
37
37
  local_spec_material_algorithm:{const:'campaign-spec-material-v1'},build_fingerprint:hash,
38
- build_fingerprint_algorithm:{const:'sha256-manifest/v1'}}),
38
+ build_fingerprint_algorithm:{const:'sha256-manifest/v1'}},['local_spec_id']),
39
39
  stages:{type:'array',minItems:6,maxItems:6,items:object({stage:enumeration(PROGRESS_STAGES),status:enumeration(PROGRESS_STAGE_STATUSES),
40
40
  build_binding:enumeration(['matching','unconfirmed']),source_build_fingerprint:hash})},
41
41
  preview:object({present:{type:'boolean'},url_hash:hash}),
@@ -46,6 +46,7 @@ export const PROGRESS_SNAPSHOT_SCHEMA = {
46
46
  binding:enumeration(['matching','unconfirmed']),publish_state:enumeration(['skipped','ok','failed','unknown'])})]},
47
47
  }),
48
48
  };
49
+
49
50
  export function canonicalProgressJson(value) {
50
51
  if (Array.isArray(value)) return `[${value.map(canonicalProgressJson).join(',')}]`;
51
52
  if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key=>`${JSON.stringify(key)}:${canonicalProgressJson(value[key])}`).join(',')}}`;
@@ -92,6 +93,7 @@ export function validateProgressSnapshot(value) {
92
93
  }
93
94
  if (!check(value,PROGRESS_SNAPSHOT_SCHEMA)) errors.push('progress.invalid_shape');
94
95
  if (!errors.length && (new Set(value.stages.map(stage=>stage.stage)).size!==6 || value.stages.some((stage,index)=>stage.stage!==PROGRESS_STAGES[index]))) errors.push('progress.invalid_stages');
96
+ if (!errors.length && value.identity.local_spec_id && (value.identity.map_id || value.identity.map_revision_hash || value.identity.saved_revision_alignment !== 'unconfirmed')) errors.push('progress.invalid_local_identity');
95
97
  if (!errors.length && value.identity.saved_revision_alignment==='aligned' && (!value.identity.map_id||!value.identity.map_revision_hash||!value.identity.local_spec_material_hash)) errors.push('progress.invalid_alignment');
96
98
  if (!errors.length && value.continuation.gates.some(gate=>gate.id==='unknown'&&gate.state!=='unknown')) errors.push('progress.unsupported_authority');
97
99
  if (!errors.length && value.sequence===1 && value.previous_snapshot_id!==null) errors.push('progress.invalid_chain');
package/src/qa-node.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion } from './qa-binding-evidence.mjs';
2
3
  import { shellToken } from "./shell-token.mjs";
3
4
  import { requiredActionText } from "./gate-actions.mjs";
@@ -13,7 +14,7 @@ import {
13
14
  import { singleLineFragment } from "./text-safety.mjs";
14
15
  import { absentOrMalformed } from "./fs-identity.mjs";
15
16
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
16
- import { specMaterialHash } from "./spec-identity.mjs";
17
+ import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
17
18
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
18
19
  import { PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
19
20
  import { dirname, join, relative, resolve, isAbsolute } from "node:path";
@@ -60,6 +61,10 @@ import {
60
61
  import { resolveConsent } from "./consent.mjs";
61
62
  import { markDoctorSidecarStale } from "./doctor-sidecar.mjs";
62
63
  import { commitAssemblyReport } from "./stage-ledger.mjs";
64
+ // Tags a refusal raised before a qa handler runs, so the CLI's lifecycle
65
+ // persist step suppresses the journal append from one place. lifecycle.mjs
66
+ // imports nothing from this repository, so this cannot be circular.
67
+ import { refused, refusing } from "./lifecycle.mjs";
63
68
  import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
64
69
  import { loadParityFixture } from "./qa-parity-fixture.mjs";
65
70
  import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
@@ -99,7 +104,7 @@ Usage:
99
104
  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
105
  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
106
  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
107
+ 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
108
  campaigns-os qa resolve <map-id> --spec <campaign-spec.json> [--base-url <url>]
104
109
  campaigns-os qa run <map-id> --spec <campaign-spec.json> --base-url <url>
105
110
  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 +151,10 @@ Options:
146
151
  per the Run Record (already_published), an untrusted one, or one for another
147
152
  campaign. Exit 2 on a refusal, 1 on a failed post, 0 when published.
148
153
  --republish qa publish: post a verdict its Run Record already records as published.
154
+ --dry-run qa publish: run every refusal check and print what would be posted (endpoint,
155
+ verdict run id, payload bytes) without the POST. Nothing is sent and the Run
156
+ Record is not stamped; a refusal still exits 2, a clean dry run exits 0
157
+ (--json: dry_run, would_publish, would_post).
149
158
  --no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
150
159
  --auth-cookie <cookie> Cookie header for protected previews.
151
160
  --browser Run Playwright-rendered browser checks after static Node checks.
@@ -250,7 +259,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
250
259
  return result;
251
260
  }
252
261
  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>`);
262
+ if (args._[2] !== "set") throw refused(`Unknown qa policy command. Use: ${cmd("qa")} policy set --packet <campaign-runtime.build.json>`);
254
263
  const result = updateQaPolicy(args);
255
264
  output(result, args);
256
265
  return result;
@@ -281,7 +290,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
281
290
  process.exitCode = result.ok ? 0 : 1;
282
291
  return result;
283
292
  }
284
- throw new Error(`Unknown qa command: ${subcommand}`);
293
+ throw refused(`Unknown qa command: ${subcommand}`);
285
294
  }
286
295
 
287
296
  // The package-owned browser install, runnable from any install mode. It is
@@ -337,8 +346,19 @@ async function resolveQaInputs(args, {
337
346
  readJsonFile = readJson,
338
347
  loadCampaignEntry = loadPageKitCampaignEntry,
339
348
  } = {}) {
349
+ // Selector flags without a non-empty value are argv-only refusals for both
350
+ // qa run and qa resolve. Check them before checkpoint preflight or site reads.
351
+ for (const flag of ["packet", "site", "built", "map-id"]) {
352
+ if (Object.hasOwn(args, flag) && !stringArg(args[flag])) {
353
+ throw refused(`Missing value for --${flag}`);
354
+ }
355
+ }
340
356
  if (args.packet && args.spec) {
341
- throw new Error("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
357
+ throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
358
+ }
359
+ // No non-empty campaign selector in argv is also decided before any read.
360
+ if (![args.packet, args.site, args.built, args._[2], args["map-id"]].some(stringArg)) {
361
+ throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
342
362
  }
343
363
  // Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
344
364
  // that has only a built _site/ and a served URL — no Build Packet, no Map ID,
@@ -363,7 +383,13 @@ async function resolveQaInputs(args, {
363
383
  const mapId = stringArg(args["map-id"])
364
384
  || stringArg(args._[2])
365
385
  || stringArg(packet?.spec?.map_id);
366
- if (!mapId) throw new Error("QA requires a Map ID. Provide --packet or positional <map-id>.");
386
+ // A named packet has been read by checkpoint preflight. Its missing or
387
+ // conflicting identity is a handler failure, not an argv-only refusal.
388
+ const localSpecId = packet?.spec?.local_spec_id ?? null;
389
+ if (!mapId && !localSpecId) throw new Error("QA requires a Map ID or a local-spec packet. The named Build Packet has no campaign identity.");
390
+ if (packet && localSpecId != null && (!resolveCampaignIdentity(packet.spec) || mapId)) {
391
+ throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
392
+ }
367
393
 
368
394
  const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
369
395
  const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
@@ -388,6 +414,13 @@ async function resolveQaInputs(args, {
388
414
  specSource = `${proxyBase.replace(/\/+$/, "")}/api/spec/${encodeURIComponent(mapId)}`;
389
415
  }
390
416
 
417
+ if (!packet && rawSpec?.spec_identity?.local_spec_id != null) {
418
+ throw new Error("Local-spec QA requires --packet; a local spec cannot be published under a Map ID.");
419
+ }
420
+ if (packet && (localSpecId != null || rawSpec?.spec_identity?.local_spec_id != null)
421
+ && !campaignIdentitiesMatch(packet.spec, campaignSpecIdentity(rawSpec))) {
422
+ throw new Error("Packet local_spec_id does not match the local CampaignSpec identity. Re-run prepare-build from the intended spec.");
423
+ }
391
424
  const normalized = normalizeSpec(rawSpec);
392
425
  const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
393
426
  const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
@@ -433,6 +466,7 @@ async function resolveQaInputs(args, {
433
466
  packetPath,
434
467
  packet,
435
468
  mapId,
469
+ localSpecId,
436
470
  publicRouteSlug,
437
471
  proxyBase,
438
472
  baseUrl,
@@ -462,6 +496,10 @@ function resolvePacketCheckpointPreflight(args, {
462
496
  } = {}) {
463
497
  const packetPath = resolve(String(args.packet));
464
498
  const packet = readJsonFile(packetPath);
499
+ if (packet?.spec?.local_spec_id != null && (!resolveCampaignIdentity(packet.spec)
500
+ || stringArg(args["map-id"]) || stringArg(args._?.[2]))) {
501
+ throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
502
+ }
465
503
  const specPath = stringArg(args.spec)
466
504
  ? resolve(String(args.spec))
467
505
  : stringArg(packet?.spec?.local_path)
@@ -497,6 +535,10 @@ function resolvePacketCheckpointPreflight(args, {
497
535
  report = null;
498
536
  }
499
537
  }
538
+ if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
539
+ || (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
540
+ throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Re-run prepare-build after a material revision; do not reuse foreign or stale proof.");
541
+ }
500
542
  const checkpointGates = [
501
543
  evaluatePageKitStoreProfile({
502
544
  specCampaign: rawSpec?.campaign || null,
@@ -532,12 +574,9 @@ function resolvePacketCheckpointPreflight(args, {
532
574
 
533
575
  function reportMatchesPacketIdentity(report, packet) {
534
576
  if (!isPlainObject(report) || !isPlainObject(report.identity)) return false;
535
- const packetMapId = stringArg(packet?.spec?.map_id);
536
- const reportMapId = stringArg(report.identity.map_id);
537
577
  const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
538
578
  const reportSlug = normalizePublicRouteSlug(report.identity.public_route_slug);
539
- return !!packetMapId
540
- && packetMapId === reportMapId
579
+ return campaignIdentitiesMatch(packet?.spec, report.identity)
541
580
  && !!packetSlug
542
581
  && packetSlug === reportSlug;
543
582
  }
@@ -624,7 +663,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
624
663
  brandContractStatus: "not_evaluated",
625
664
  packetPath: preflight.packetPath,
626
665
  packet: preflight.packet,
627
- mapId: stringArg(preflight.packet?.spec?.map_id) || "unknown-map",
666
+ mapId: stringArg(preflight.packet?.spec?.map_id) || (preflight.packet?.spec?.local_spec_id ? null : "unknown-map"),
667
+ localSpecId: preflight.packet?.spec?.local_spec_id ?? null,
628
668
  publicRouteSlug,
629
669
  proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
630
670
  baseUrl: inputBaseUrl,
@@ -650,18 +690,18 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
650
690
  // yields "not_applicable" rather than blocking, so browser QA still runs the
651
691
  // residue/placeholder/demo gates. Test orders are not attempted (no policy).
652
692
  export function resolveQaInputsFromSite(args) {
653
- const targetRepo = resolve(String(args.site || args.built));
654
- const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
655
- if (!scope.ok) {
656
- throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
657
- }
658
693
  const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
659
694
  if (!baseUrl) {
660
- throw new Error("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
695
+ throw refused("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
661
696
  }
662
697
  const templateFamily = stringArg(args.family);
663
698
  if (!templateFamily) {
664
- throw new Error("Non-packet site QA requires --family <template-family> so residue, placeholder, and demo-asset gates can load the family brand contract.");
699
+ throw refused("Non-packet site QA requires --family <template-family> so residue, placeholder, and demo-asset gates can load the family brand contract.");
700
+ }
701
+ const targetRepo = resolve(String(args.site || args.built));
702
+ const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
703
+ if (!scope.ok) {
704
+ throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
665
705
  }
666
706
  const brandContract = loadBrandContract(templateFamily);
667
707
  if (brandContract.status !== "loaded" || !brandContract.contract) {
@@ -1490,6 +1530,7 @@ function resolvePayload(resolved, { routeProbe = null } = {}) {
1490
1530
  ok: status !== "blocked" && status !== "routes_unresolved",
1491
1531
  status,
1492
1532
  map_id: resolved.mapId,
1533
+ ...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
1493
1534
  ...(resolved.packetPath ? { packet_path: resolved.packetPath } : {}),
1494
1535
  ...reportPathField(resolved),
1495
1536
  ...(resolved.proxyBase && resolved.proxyBase !== DEFAULT_PROXY_BASE ? { proxy_base: resolved.proxyBase } : {}),
@@ -1726,7 +1767,7 @@ const REMOVED_QA_POLICY_FLAGS = ["test-orders-allowed", "sandbox-test-card-confi
1726
1767
 
1727
1768
  function updateQaPolicy(args) {
1728
1769
  const packetPath = args.packet ? resolve(args.packet) : null;
1729
- if (!packetPath) throw new Error("qa policy set requires --packet <campaign-runtime.build.json>.");
1770
+ if (!packetPath) throw refused("qa policy set requires --packet <campaign-runtime.build.json>.");
1730
1771
  const packet = readJson(packetPath);
1731
1772
  packet.campaign ||= {};
1732
1773
  packet.deploy ||= {};
@@ -1735,12 +1776,15 @@ function updateQaPolicy(args) {
1735
1776
  // Test Orders have no permission flag. The two flags that once set one
1736
1777
  // were removed with their packet fields in supported surface 1.28.0; a
1737
1778
  // script still passing them gets told so instead of a silent no-op.
1779
+ // This check and the setOptional* value checks below run after reading only
1780
+ // the packet and before the packet write: refusals, so they journal nothing.
1738
1781
  const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
1739
1782
  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.`);
1783
+ 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
1784
  }
1742
- // Validated with the other argv checks, before anything is written.
1743
- const orderPathDepth = parseOrderPathDepthFlag(args, { command: "qa policy set" });
1785
+ // Validated with the other argv checks, before anything is written: a
1786
+ // refusal at this call site, like the removed-flag check above.
1787
+ const orderPathDepth = refusing(() => parseOrderPathDepthFlag(args, { command: "qa policy set" }));
1744
1788
 
1745
1789
  const changed = [];
1746
1790
  setOptionalBoolean(packet.campaign, "allowed_domains_confirmed", args, "allowed-domains-confirmed", changed);
@@ -1810,21 +1854,24 @@ const WAIVABLE_QA_ASSERTIONS = Object.freeze(["analytics-correctness:purchase-fi
1810
1854
  // report.theme.waiver: { reason, waived_by, waived_at }.
1811
1855
  export function qaWaive(args) {
1812
1856
  const packetPath = args.packet ? resolve(args.packet) : null;
1813
- if (!packetPath) throw new Error("qa waive requires --packet <campaign-runtime.build.json>.");
1857
+ if (!packetPath) throw refused("qa waive requires --packet <campaign-runtime.build.json>.");
1814
1858
  const packet = readJson(packetPath);
1859
+ // The three flag checks run after reading only argv and the packet, ahead of
1860
+ // the report lookup: refusals, so they journal nothing. The missing report
1861
+ // below is a handler failure and is journaled.
1815
1862
  const assertionId = stringArg(args.assertion);
1816
1863
  if (!assertionId) {
1817
- throw new Error(`qa waive requires --assertion <id>. Waivable assertions: ${WAIVABLE_QA_ASSERTIONS.join(", ")}.`);
1864
+ throw refused(`qa waive requires --assertion <id>. Waivable assertions: ${WAIVABLE_QA_ASSERTIONS.join(", ")}.`);
1818
1865
  }
1819
1866
  if (!WAIVABLE_QA_ASSERTIONS.includes(assertionId)) {
1820
- throw new Error(
1867
+ throw refused(
1821
1868
  `qa waive does not accept --assertion "${assertionId}". The waiver lane is scoped to exactly: ${WAIVABLE_QA_ASSERTIONS.join(", ")}. `
1822
1869
  + "Extending the lane to another assertion is a design decision, not a flag.",
1823
1870
  );
1824
1871
  }
1825
1872
  const reason = stringArg(args.reason);
1826
1873
  if (!reason) {
1827
- throw new Error("qa waive requires --reason \"<why this failing blocker is acceptable for this campaign>\".");
1874
+ throw refused("qa waive requires --reason \"<why this failing blocker is acceptable for this campaign>\".");
1828
1875
  }
1829
1876
  const workspace = resolveCampaignWorkspace(packetPath, {
1830
1877
  packet,
@@ -1998,15 +2045,25 @@ function parityReplayEvidence(bundle) {
1998
2045
  return { order, capture, baselineCapture, orders: Array.isArray(bundle.orders) ? bundle.orders : [order] };
1999
2046
  }
2000
2047
 
2048
+ // The up-front half of the `--max-order-creations` check. The validator is
2049
+ // SHARED with the order-creation budget, which every browser path builds after
2050
+ // a browser has launched and orders may already have been created; a throw from
2051
+ // there is a handler failure and must still be journaled, so the refusal tag
2052
+ // cannot live inside the validator. It goes here via `refusing()`, at the two
2053
+ // entries that check the flag before anything is resolved or launched, where a
2054
+ // bad value has cost the operator nothing. Message and exit code are the
2055
+ // validator's own.
2056
+ const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
2057
+
2001
2058
  async function runParityQa(args) {
2002
2059
  // Checked here as well as on the budget itself: the budget is built after a
2003
2060
  // browser has launched, and a flag the operator typed wrong should cost them
2004
2061
  // nothing. The budget stays the authority — this is fail-fast, not the gate.
2005
- validatedOrderCreationLimit(args);
2062
+ refuseBadOrderCreationLimit(args);
2006
2063
  const fixturePath = stringArg(args.fixture);
2007
2064
  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>.");
2065
+ if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
2066
+ if (!scenarioId) throw refused("QA parity requires --scenario <scenario-id>.");
2010
2067
 
2011
2068
  const fixture = await loadParityFixture(resolve(fixturePath));
2012
2069
  const scenario = resolveParityScenario(fixture, scenarioId);
@@ -2072,7 +2129,7 @@ async function runParityQa(args) {
2072
2129
  async function runQa(args, options = {}) {
2073
2130
  // Fail-fast before anything resolves or launches. The authoritative check
2074
2131
  // lives on the creation budget itself, which every browser path builds.
2075
- validatedOrderCreationLimit(args);
2132
+ refuseBadOrderCreationLimit(args);
2076
2133
  const resolved = await resolveQaInputs(args);
2077
2134
  return runResolvedQa(args, resolved, options);
2078
2135
  }
@@ -2279,12 +2336,14 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2279
2336
  baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
2280
2337
  targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
2281
2338
  mapId: resolved.mapId,
2339
+ localSpecId: resolved.localSpecId,
2282
2340
  currentRunId: runId,
2283
2341
  isFinding: isFindingAssertion,
2284
2342
  });
2285
2343
  const verdict = createVerdict({
2286
2344
  runId,
2287
2345
  mapId: resolved.mapId,
2346
+ localSpecId: resolved.localSpecId,
2288
2347
  publicRouteSlug: resolved.publicRouteSlug || null,
2289
2348
  campaignRefId: resolved.spec.campaign?.ref_id || null,
2290
2349
  specVersion: resolved.specVersion,
@@ -2338,7 +2397,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2338
2397
  // config, or scope mismatch should be visible on the QA path too, not just
2339
2398
  // on remit (Kilo review, PR #177).
2340
2399
  const consent = resolveConsent({ proxyBase: resolved.proxyBase });
2341
- const publishDecision = decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
2400
+ const publishDecision = resolved.localSpecId
2401
+ ? { publish: false, reason: "local_spec", flag_invalid: false }
2402
+ : decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
2342
2403
  if (publishDecision.flag_invalid) {
2343
2404
  process.stderr.write(`[campaigns-os] --post-verdict "${args["post-verdict"]}" is not a recognized value (use true|1|yes|y|on or false|0|no|n|off); the flag was ignored and the default publish decision applied.\n`);
2344
2405
  }
@@ -2358,6 +2419,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2358
2419
  status: verdict.disposition,
2359
2420
  run_id: verdict.run_id,
2360
2421
  map_id: resolved.mapId,
2422
+ ...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
2361
2423
  public_route_slug: resolved.publicRouteSlug || null,
2362
2424
  ...reportPathField(resolved),
2363
2425
  base_url: resolved.baseUrl,
@@ -2983,7 +3045,7 @@ function output(value, args) {
2983
3045
  }
2984
3046
  if (value.verdict) {
2985
3047
  console.log(`QA run complete.`);
2986
- console.log(`Map ID: ${value.map_id}`);
3048
+ console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
2987
3049
  console.log(`Base URL: ${value.base_url || "(missing)"}`);
2988
3050
  printEntryUrlLines(value.entry_urls);
2989
3051
  console.log(`Run ID: ${value.run_id}`);
@@ -3016,7 +3078,7 @@ function output(value, args) {
3016
3078
  }
3017
3079
  console.log(`QA resolve complete.`);
3018
3080
  console.log(`Status: ${value.status}`);
3019
- console.log(`Map ID: ${value.map_id}`);
3081
+ console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
3020
3082
  console.log(`Spec: ${value.spec_source}`);
3021
3083
  console.log(`Base URL: ${value.base_url || "(missing)"}`);
3022
3084
  printEntryUrlLines(value.entry_urls);
@@ -3166,7 +3228,9 @@ export function qaResolveNextProofLines(value) {
3166
3228
  return [
3167
3229
  `Next expected proof: ${qaRunCommandFromResolve(value)}`,
3168
3230
  `Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
3169
- "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3231
+ value.local_spec_id
3232
+ ? "Typed-card test orders use global test cards (no transactions/no permission gate); local-spec QA stays in the repository."
3233
+ : "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3170
3234
  ];
3171
3235
  }
3172
3236
 
@@ -3232,16 +3296,21 @@ function stringArg(value) {
3232
3296
  return typeof value === "string" && value.trim() ? value.trim() : null;
3233
3297
  }
3234
3298
 
3299
+ // setOptionalBoolean and setOptionalString serve `qa policy set` only, ahead of
3300
+ // its packet write, so a bad value there is a refusal. booleanArg itself stays
3301
+ // untagged: it is shared with `qa run`'s analytics leg, which calls it mid-run,
3302
+ // where a throw is a handler failure — so the tag goes on this call site
3303
+ // (`refusing()`), not in the validator.
3235
3304
  function setOptionalBoolean(target, property, args, key, changed) {
3236
3305
  if (!(key in args)) return;
3237
- const value = booleanArg(args[key], key);
3306
+ const value = refusing(() => booleanArg(args[key], key));
3238
3307
  setIfChanged(target, property, value, changed);
3239
3308
  }
3240
3309
 
3241
3310
  function setOptionalString(target, property, args, key, changed) {
3242
3311
  if (!(key in args)) return;
3243
3312
  const value = stringArg(args[key]);
3244
- if (!value) throw new Error(`--${key} requires a value.`);
3313
+ if (!value) throw refused(`--${key} requires a value.`);
3245
3314
  setIfChanged(target, property, value, changed);
3246
3315
  }
3247
3316