@nextcommerce/campaigns-os 1.41.2 → 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 (56) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +6 -4
  3. package/agents/claude/CLAUDE.md +5 -1
  4. package/campaign-spec/dist/types.d.ts +2 -0
  5. package/contracts/effects.v1.json +111 -18
  6. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  7. package/contracts/release-ledger.json +752 -0
  8. package/contracts/supported-surface.json +12 -11
  9. package/docs/build-packet.md +56 -6
  10. package/docs/campaigns-os-build-flow.md +2 -2
  11. package/docs/effects.md +70 -1
  12. package/docs/local-setup.md +51 -0
  13. package/docs/migration-sidecar-bundle.md +6 -1
  14. package/docs/orientation-contract-reference.md +1 -1
  15. package/docs/progress-snapshots.md +6 -0
  16. package/docs/qa-and-test-orders.md +26 -10
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +3 -2
  20. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  21. package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
  22. package/schemas/campaign-spec.v4.schema.json +4 -0
  23. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  24. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  25. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  26. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  27. package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
  28. package/skills/campaign-readback-classification/SKILL.md +3 -3
  29. package/skills/campaign-run-evidence/SKILL.md +8 -6
  30. package/skills/contribution-intake/SKILL.md +3 -3
  31. package/skills/next-campaigns-build/SKILL.md +3 -3
  32. package/skills/next-campaigns-os/SKILL.md +17 -4
  33. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  34. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  35. package/skills/next-campaigns-polish/SKILL.md +3 -3
  36. package/skills/next-campaigns-qa/SKILL.md +10 -9
  37. package/skills.json +11 -11
  38. package/src/cli.mjs +187 -70
  39. package/src/finding-cause.mjs +14 -10
  40. package/src/lifecycle.mjs +5 -4
  41. package/src/polish-node.mjs +5 -2
  42. package/src/progress-node.mjs +3 -2
  43. package/src/progress.mjs +5 -3
  44. package/src/qa-node.mjs +58 -26
  45. package/src/qa-publish.mjs +4 -0
  46. package/src/qa-sidecar.mjs +2 -0
  47. package/src/qa-verdict-discovery.mjs +11 -0
  48. package/src/qa-verdict-publish.mjs +1 -0
  49. package/src/qa-verdict.mjs +8 -1
  50. package/src/readback.mjs +2 -1
  51. package/src/run-record-closeout.mjs +3 -4
  52. package/src/run-record.mjs +4 -0
  53. package/src/sidecar-bundle.mjs +21 -0
  54. package/src/spec-source-identity.mjs +44 -0
  55. package/src/stage-ledger.mjs +4 -1
  56. package/src/tooling-setup.mjs +160 -0
package/src/lifecycle.mjs CHANGED
@@ -78,10 +78,11 @@ export function runWithRefusalScope(fn) {
78
78
  * the throw, because the paths that catch a refusal to render it (waiveOrRefuse)
79
79
  * hand onFinish no error to inspect. The cost of marking early is that a
80
80
  * `refused()` built inside a `try` that discards it would suppress the journal
81
- * entry for an invocation whose handler did run. No call site does that today
82
- * (no `requireArg` sits inside a `try`), and none may: if you need to probe
83
- * whether an argument is present, test for it — do not construct a refusal
84
- * speculatively.
81
+ * entry for an invocation whose handler did run. The optional QA progress
82
+ * probe checks for a packet before its swallowing `try`; closeRunSession runs
83
+ * its nested run-record attempt in a separate refusal scope. Keep those
84
+ * boundaries: if you need to probe an argument, test for it rather than
85
+ * constructing a refusal speculatively.
85
86
  */
86
87
  export function refused(message) {
87
88
  const store = refusalScope.getStore();
@@ -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";
@@ -345,9 +346,20 @@ async function resolveQaInputs(args, {
345
346
  readJsonFile = readJson,
346
347
  loadCampaignEntry = loadPageKitCampaignEntry,
347
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
+ }
348
356
  if (args.packet && args.spec) {
349
357
  throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
350
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>.");
362
+ }
351
363
  // Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
352
364
  // that has only a built _site/ and a served URL — no Build Packet, no Map ID,
353
365
  // no CampaignSpec. Scope (pages + funnel types) is resolved from the built
@@ -371,15 +383,13 @@ async function resolveQaInputs(args, {
371
383
  const mapId = stringArg(args["map-id"])
372
384
  || stringArg(args._[2])
373
385
  || stringArg(packet?.spec?.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>.");
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
+ }
383
393
 
384
394
  const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
385
395
  const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
@@ -404,6 +414,13 @@ async function resolveQaInputs(args, {
404
414
  specSource = `${proxyBase.replace(/\/+$/, "")}/api/spec/${encodeURIComponent(mapId)}`;
405
415
  }
406
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
+ }
407
424
  const normalized = normalizeSpec(rawSpec);
408
425
  const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
409
426
  const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
@@ -449,6 +466,7 @@ async function resolveQaInputs(args, {
449
466
  packetPath,
450
467
  packet,
451
468
  mapId,
469
+ localSpecId,
452
470
  publicRouteSlug,
453
471
  proxyBase,
454
472
  baseUrl,
@@ -478,6 +496,10 @@ function resolvePacketCheckpointPreflight(args, {
478
496
  } = {}) {
479
497
  const packetPath = resolve(String(args.packet));
480
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
+ }
481
503
  const specPath = stringArg(args.spec)
482
504
  ? resolve(String(args.spec))
483
505
  : stringArg(packet?.spec?.local_path)
@@ -513,6 +535,10 @@ function resolvePacketCheckpointPreflight(args, {
513
535
  report = null;
514
536
  }
515
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
+ }
516
542
  const checkpointGates = [
517
543
  evaluatePageKitStoreProfile({
518
544
  specCampaign: rawSpec?.campaign || null,
@@ -548,12 +574,9 @@ function resolvePacketCheckpointPreflight(args, {
548
574
 
549
575
  function reportMatchesPacketIdentity(report, packet) {
550
576
  if (!isPlainObject(report) || !isPlainObject(report.identity)) return false;
551
- const packetMapId = stringArg(packet?.spec?.map_id);
552
- const reportMapId = stringArg(report.identity.map_id);
553
577
  const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
554
578
  const reportSlug = normalizePublicRouteSlug(report.identity.public_route_slug);
555
- return !!packetMapId
556
- && packetMapId === reportMapId
579
+ return campaignIdentitiesMatch(packet?.spec, report.identity)
557
580
  && !!packetSlug
558
581
  && packetSlug === reportSlug;
559
582
  }
@@ -640,7 +663,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
640
663
  brandContractStatus: "not_evaluated",
641
664
  packetPath: preflight.packetPath,
642
665
  packet: preflight.packet,
643
- 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,
644
668
  publicRouteSlug,
645
669
  proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
646
670
  baseUrl: inputBaseUrl,
@@ -666,18 +690,18 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
666
690
  // yields "not_applicable" rather than blocking, so browser QA still runs the
667
691
  // residue/placeholder/demo gates. Test orders are not attempted (no policy).
668
692
  export function resolveQaInputsFromSite(args) {
669
- const targetRepo = resolve(String(args.site || args.built));
670
- const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
671
- if (!scope.ok) {
672
- throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
673
- }
674
693
  const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
675
694
  if (!baseUrl) {
676
- 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.");
677
696
  }
678
697
  const templateFamily = stringArg(args.family);
679
698
  if (!templateFamily) {
680
- 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}.`);
681
705
  }
682
706
  const brandContract = loadBrandContract(templateFamily);
683
707
  if (brandContract.status !== "loaded" || !brandContract.contract) {
@@ -1506,6 +1530,7 @@ function resolvePayload(resolved, { routeProbe = null } = {}) {
1506
1530
  ok: status !== "blocked" && status !== "routes_unresolved",
1507
1531
  status,
1508
1532
  map_id: resolved.mapId,
1533
+ ...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
1509
1534
  ...(resolved.packetPath ? { packet_path: resolved.packetPath } : {}),
1510
1535
  ...reportPathField(resolved),
1511
1536
  ...(resolved.proxyBase && resolved.proxyBase !== DEFAULT_PROXY_BASE ? { proxy_base: resolved.proxyBase } : {}),
@@ -2311,12 +2336,14 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2311
2336
  baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
2312
2337
  targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
2313
2338
  mapId: resolved.mapId,
2339
+ localSpecId: resolved.localSpecId,
2314
2340
  currentRunId: runId,
2315
2341
  isFinding: isFindingAssertion,
2316
2342
  });
2317
2343
  const verdict = createVerdict({
2318
2344
  runId,
2319
2345
  mapId: resolved.mapId,
2346
+ localSpecId: resolved.localSpecId,
2320
2347
  publicRouteSlug: resolved.publicRouteSlug || null,
2321
2348
  campaignRefId: resolved.spec.campaign?.ref_id || null,
2322
2349
  specVersion: resolved.specVersion,
@@ -2370,7 +2397,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2370
2397
  // config, or scope mismatch should be visible on the QA path too, not just
2371
2398
  // on remit (Kilo review, PR #177).
2372
2399
  const consent = resolveConsent({ proxyBase: resolved.proxyBase });
2373
- 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 });
2374
2403
  if (publishDecision.flag_invalid) {
2375
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`);
2376
2405
  }
@@ -2390,6 +2419,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2390
2419
  status: verdict.disposition,
2391
2420
  run_id: verdict.run_id,
2392
2421
  map_id: resolved.mapId,
2422
+ ...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
2393
2423
  public_route_slug: resolved.publicRouteSlug || null,
2394
2424
  ...reportPathField(resolved),
2395
2425
  base_url: resolved.baseUrl,
@@ -3015,7 +3045,7 @@ function output(value, args) {
3015
3045
  }
3016
3046
  if (value.verdict) {
3017
3047
  console.log(`QA run complete.`);
3018
- 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}`);
3019
3049
  console.log(`Base URL: ${value.base_url || "(missing)"}`);
3020
3050
  printEntryUrlLines(value.entry_urls);
3021
3051
  console.log(`Run ID: ${value.run_id}`);
@@ -3048,7 +3078,7 @@ function output(value, args) {
3048
3078
  }
3049
3079
  console.log(`QA resolve complete.`);
3050
3080
  console.log(`Status: ${value.status}`);
3051
- 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}`);
3052
3082
  console.log(`Spec: ${value.spec_source}`);
3053
3083
  console.log(`Base URL: ${value.base_url || "(missing)"}`);
3054
3084
  printEntryUrlLines(value.entry_urls);
@@ -3198,7 +3228,9 @@ export function qaResolveNextProofLines(value) {
3198
3228
  return [
3199
3229
  `Next expected proof: ${qaRunCommandFromResolve(value)}`,
3200
3230
  `Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
3201
- "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.",
3202
3234
  ];
3203
3235
  }
3204
3236
 
@@ -46,6 +46,7 @@ export const QA_PUBLISH_STATUSES = Object.freeze({
46
46
  // thrown error, so --json readers get a code to branch on.
47
47
  export const QA_PUBLISH_REFUSALS = Object.freeze({
48
48
  packet_required: "packet_required",
49
+ local_spec: "local_spec",
49
50
  order_flags_refused: "order_flags_refused",
50
51
  verdict_missing: "verdict_missing",
51
52
  verdict_unreadable: "verdict_unreadable",
@@ -218,6 +219,9 @@ async function attemptPublish(args, operations, dryRun) {
218
219
  return refusal(QA_PUBLISH_REFUSALS.packet_required, `Build Packet ${packetPath} is not readable (${error.code || error.message}).`);
219
220
  }
220
221
 
222
+ if (packet?.spec?.local_spec_id != null) {
223
+ return refusal(QA_PUBLISH_REFUSALS.local_spec, "Local-spec QA has no saved Map destination; keep its verdict in the repository. Portal publication requires a saved Map and fresh evidence.");
224
+ }
221
225
  const source = resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile: ops.readJsonFile, exists: ops.exists });
222
226
  if (source.error) return source.error;
223
227
  let verdict;
@@ -1,3 +1,4 @@
1
+ import { localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -77,6 +78,7 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
77
78
  schema_version: verdict.schema_version,
78
79
  run_id: verdict.run_id,
79
80
  campaign_slug: verdict.campaign_slug,
81
+ ...localSpecIdentityFields(verdict),
80
82
  ...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
81
83
  ...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
82
84
  spec_version: verdict.spec_version,
@@ -1,3 +1,4 @@
1
+ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
1
2
  // QA verdict discovery: the one walk over the local verdicts a campaign has
2
3
  // written, with the projections each reader needs.
3
4
  //
@@ -28,11 +29,20 @@ function qaVerdictSlug(packet) {
28
29
  // the slug as identity. A verdict is this campaign's when its campaign_slug
29
30
  // is one of them.
30
31
  export function qaVerdictIdentifiers(packet) {
32
+ if (packet?.spec?.local_spec_id != null) {
33
+ const identity = resolveCampaignIdentity(packet.spec);
34
+ return identity?.kind === "local_spec" ? [localQaIdentifier(identity.id)] : [];
35
+ }
31
36
  return [...new Set([optionalString(packet?.spec?.map_id), qaVerdictSlug(packet)].filter(Boolean))];
32
37
  }
33
38
 
34
39
  export function qaVerdictIdentityMatch(verdict, packet) {
35
40
  const slug = optionalString(verdict?.campaign_slug);
41
+ if (packet?.spec?.local_spec_id != null || verdict?.local_spec_id != null) {
42
+ const identity = resolveCampaignIdentity(packet?.spec);
43
+ return identity?.kind === "local_spec" && verdict?.local_spec_id === identity.id
44
+ && slug === localQaIdentifier(identity.id);
45
+ }
36
46
  return Boolean(slug) && qaVerdictIdentifiers(packet).includes(slug);
37
47
  }
38
48
 
@@ -174,6 +184,7 @@ export function qaVerdictCandidateScore(candidate, packet) {
174
184
  const mapId = optionalString(packet?.spec?.map_id);
175
185
  const slug = qaVerdictSlug(packet);
176
186
  let score = 0;
187
+ if (packet?.spec?.local_spec_id && qaVerdictIdentityMatch(verdict, packet)) score += 100;
177
188
  if (mapId && verdict.campaign_slug === mapId) score += 100;
178
189
  if (slug && verdict.campaign_slug === slug) score += 80;
179
190
  if (verdict.schema_version === "1.0" || verdict.schema_version === "campaigns-os-qa-verdict/v0") score += 10;
@@ -42,6 +42,7 @@ export const QA_VERDICT_PUBLISH_STATES = Object.freeze(["skipped", "ok", "failed
42
42
  * must not claim a credential is travelling in clear.
43
43
  */
44
44
  export async function publishQaVerdict(verdict, proxyBase, { fetchImpl = globalThis.fetch, remitImpl = remit } = {}) {
45
+ if (verdict?.local_spec_id != null) return { ...skippedQaVerdictPublish(), reason: "local_spec" };
45
46
  let httpStatus = null;
46
47
  let response = null;
47
48
  let failure = null;
@@ -1,3 +1,4 @@
1
+ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
1
2
  export const QA_SCHEMA_VERSION = "1.0";
2
3
 
3
4
  export const STATUS = Object.freeze({
@@ -95,6 +96,7 @@ function dispositionWithCommercial(assertions, commercial) {
95
96
  export function createVerdict({
96
97
  runId,
97
98
  mapId,
99
+ localSpecId = null,
98
100
  publicRouteSlug = null,
99
101
  campaignRefId = null,
100
102
  specVersion,
@@ -126,7 +128,8 @@ export function createVerdict({
126
128
  run_id: runId,
127
129
  // campaign_slug carries the Map ID for schema back-compat; the true public
128
130
  // route slug rides alongside so consumers stop conflating the two.
129
- campaign_slug: mapId,
131
+ campaign_slug: localSpecId ? localQaIdentifier(localSpecId) : mapId,
132
+ ...(localSpecId ? { local_spec_id: localSpecId } : {}),
130
133
  public_route_slug: optionalString(publicRouteSlug),
131
134
  campaign_ref_id: campaignRefId,
132
135
  spec_version: specVersion,
@@ -208,6 +211,10 @@ export function deriveExceptions(assertions = []) {
208
211
 
209
212
  export function validateVerdict(verdict) {
210
213
  const errors = [];
214
+ if (verdict?.local_spec_id != null && (!resolveCampaignIdentity({ local_spec_id: verdict.local_spec_id })
215
+ || verdict.campaign_slug !== localQaIdentifier(verdict.local_spec_id))) {
216
+ errors.push("Local-spec verdict requires a valid local_spec_id and its local QA identifier.");
217
+ }
211
218
  if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
212
219
  return ["verdict: must be an object"];
213
220
  }
package/src/readback.mjs CHANGED
@@ -1187,6 +1187,7 @@ function renderIdentity(views, lines) {
1187
1187
  const spec = packet.spec || {};
1188
1188
  const campaign = packet.campaign || {};
1189
1189
  if (spec.map_id) entries.push(["map_id", spec.map_id, "build packet"]);
1190
+ if (spec.local_spec_id) entries.push(["local_spec_id", spec.local_spec_id, "build packet"]);
1190
1191
  if (campaign.public_route_slug) {
1191
1192
  entries.push(["public_route_slug", campaign.public_route_slug, "build packet"]);
1192
1193
  }
@@ -1194,7 +1195,7 @@ function renderIdentity(views, lines) {
1194
1195
  if (assembly.template_family) entries.push(["template_family", assembly.template_family, "build packet"]);
1195
1196
  } else if (doctor) {
1196
1197
  const derived = doctor.derived || {};
1197
- for (const field of ["map_id", "public_route_slug", "template_family"]) {
1198
+ for (const field of ["map_id", "local_spec_id", "public_route_slug", "template_family"]) {
1198
1199
  if (derived[field]) entries.push([field, derived[field], "doctor output"]);
1199
1200
  }
1200
1201
  }
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  // Run Record closeout recognition.
2
3
  //
3
4
  // `next` at stage "done" used to demand a Run Record unconditionally, because
@@ -93,13 +94,11 @@ function qaVerdictDigests(record) {
93
94
  * satisfy closeout — and `run-record` never re-emits under its id.
94
95
  */
95
96
  export function identityMatches(record, packet) {
96
- const mapId = text(packet?.spec?.map_id);
97
97
  const slug = text(packet?.campaign?.public_route_slug);
98
98
  const identity = isObject(record?.identity) ? record.identity : {};
99
- const recordMapId = text(identity.map_id);
100
99
  const recordSlug = text(identity.campaign_slug);
101
- if (!mapId || !slug || !recordMapId || !recordSlug) return false;
102
- return recordMapId === mapId && recordSlug === slug;
100
+ if (!slug || !recordSlug) return false;
101
+ return campaignIdentitiesMatch(identity, packet?.spec) && recordSlug === slug;
103
102
  }
104
103
 
105
104
  function outcome(reason_code, detail, entry = null) {
@@ -1,3 +1,4 @@
1
+ import { localSpecIdentityFields, resolveCampaignIdentity } from "./spec-source-identity.mjs";
1
2
  // Run Telemetry — per-run Run Record capture for Campaigns OS.
2
3
  // See docs/workflow-findings-sidecar.md (Run Telemetry).
3
4
  //
@@ -155,6 +156,8 @@ export function validateRunRecord(record) {
155
156
  if (record.identity != null) {
156
157
  if (typeof record.identity !== "object" || Array.isArray(record.identity)) {
157
158
  add("record.identity", "identity must be an object when present.");
159
+ } else if (record.identity.local_spec_id != null && resolveCampaignIdentity(record.identity)?.kind !== "local_spec") {
160
+ add("record.identity.local_spec_id", "local_spec_id must be a canonical local ID with no saved Map identity.");
158
161
  }
159
162
  }
160
163
 
@@ -449,6 +452,7 @@ export function selectRunFindingIds(journal, runId) {
449
452
  function normalizeIdentity(identity = {}) {
450
453
  return {
451
454
  map_id: identity.map_id ?? null,
455
+ ...localSpecIdentityFields(identity),
452
456
  campaign_slug: identity.campaign_slug ?? null,
453
457
  template_family: identity.template_family ?? null,
454
458
  entry_point_shape: identity.entry_point_shape ?? null,
@@ -84,6 +84,27 @@ function identityValuesAgree(identityField, left, right) {
84
84
  }
85
85
 
86
86
  function compareIdentity(errors, records, identityField) {
87
+ const local = identityField.local_spec_alternative;
88
+ if (local && records.get("build_packet")?.value?.spec?.local_spec_id != null) {
89
+ compareIdentity(errors, records, local);
90
+ // A local identity can never borrow a Map identity from another artifact.
91
+ for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
92
+ if (kind === "qa_verdict") continue; // campaign_slug is the local QA storage key.
93
+ if (valueAt(records.get(kind)?.value, path) != null) {
94
+ errors.push(artifactFinding("bundle.identity.map_id_mismatch", kind,
95
+ "Local-spec bundle carries a saved Map identity.", "Regenerate artifacts from the same local CampaignSpec."));
96
+ }
97
+ }
98
+ return;
99
+ }
100
+ if (local) {
101
+ for (const [kind, path] of Object.entries(local.artifact_paths)) {
102
+ if (valueAt(records.get(kind)?.value, path) != null) {
103
+ errors.push(artifactFinding("bundle.identity.local_spec_id_mismatch", kind,
104
+ "Saved-Map bundle carries local-spec evidence.", "Regenerate artifacts from the same saved Map."));
105
+ }
106
+ }
107
+ }
87
108
  const label = identityField.name;
88
109
  const present = [];
89
110
  for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
@@ -0,0 +1,44 @@
1
+ // Stable campaign identity is separate from both the public route and the
2
+ // material spec hash. Local IDs never identify a saved Map or a portal URL.
3
+ export const LOCAL_SPEC_ID_PATTERN = "^[A-Za-z0-9_-]{1,64}$";
4
+ const localIdPattern = new RegExp(LOCAL_SPEC_ID_PATTERN);
5
+ const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
6
+
7
+ export function campaignSpecIdentity(spec) {
8
+ return {
9
+ map_id: spec?.spec_identity?.map_id ?? spec?.map_id ?? null,
10
+ local_spec_id: spec?.spec_identity?.local_spec_id ?? null,
11
+ };
12
+ }
13
+
14
+ export function resolveCampaignIdentity(fields) {
15
+ // Saved Map IDs retain their existing whitespace normalization. Local IDs
16
+ // are canonical, repository-owned tokens: never trim one into another ID.
17
+ const mapId = text(fields?.map_id);
18
+ const localId = fields?.local_spec_id;
19
+ if (localId != null) {
20
+ if (fields?.map_id != null || typeof localId !== "string" || !localIdPattern.test(localId)) return null;
21
+ return { kind: "local_spec", id: localId };
22
+ }
23
+ return mapId ? { kind: "saved_map", id: mapId } : null;
24
+ }
25
+
26
+ export function campaignIdentitiesMatch(left, right) {
27
+ const a = resolveCampaignIdentity(left);
28
+ const b = resolveCampaignIdentity(right);
29
+ return !!a && !!b && a.kind === b.kind && a.id === b.id;
30
+ }
31
+
32
+ export function localSpecIdentityFields(fields) {
33
+ if (fields?.local_spec_id == null) return {};
34
+ const identity = resolveCampaignIdentity(fields);
35
+ // Omitting a malformed marker would let a conflicting identity fall back to
36
+ // its Map ID. Writers must refuse it rather than silently change its kind.
37
+ if (identity?.kind !== "local_spec") throw new Error("Invalid local_spec_id or conflicting saved Map identity.");
38
+ return { local_spec_id: identity.id };
39
+ }
40
+
41
+ export function localQaIdentifier(localSpecId) {
42
+ if (typeof localSpecId !== "string" || !localIdPattern.test(localSpecId)) throw new Error("Invalid local_spec_id.");
43
+ return `local-spec-${localSpecId}`;
44
+ }
@@ -1,3 +1,4 @@
1
+ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
3
4
  import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
@@ -377,7 +378,9 @@ export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint })
377
378
  */
378
379
  export function assemblyReportMatchesPacket(report, packet) {
379
380
  return isPlainObject(report)
380
- && optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id)
381
+ && (report?.identity?.local_spec_id != null || packet?.spec?.local_spec_id != null
382
+ ? campaignIdentitiesMatch(report?.identity, packet?.spec)
383
+ : optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id))
381
384
  && optionalString(report?.identity?.public_route_slug) === optionalString(packet?.campaign?.public_route_slug);
382
385
  }
383
386