@nextcommerce/campaigns-os 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,1300 @@
1
+ # Build Packet
2
+
3
+ The Build Packet is the campaign assembly handoff. It wraps, but does not replace, the CampaignSpec.
4
+
5
+ It answers:
6
+
7
+ - Which CampaignSpec and Map ID are we building?
8
+ - Which public route slug and campaign directory are expected?
9
+ - Where are the prepared HTML/assets?
10
+ - Which Campaign Build Brief is the merchandising/design presentation truth?
11
+ - Which target page-kit repo should be updated?
12
+ - Which starter template family is locked?
13
+ - Which commerce catalog/contract should the agent read?
14
+ - Which deploy target, SDK origin state, and QA proof depth apply?
15
+
16
+ The current schema is `schemas/campaign-runtime-build-packet.v0.schema.json`.
17
+
18
+ ## Root-Served Campaigns (`campaign.route_root`)
19
+
20
+ Most campaigns are served under a slug prefix (`/<public_route_slug>/...`), and
21
+ that stays the default. A campaign whose whole funnel is served from the **site
22
+ root** — pages at `/checkout-v2`, `/oto-ruggie`, `/receipt` with no slug prefix,
23
+ the normal shape for a single-campaign site or an in-place static deploy —
24
+ declares `campaign.route_root: "/"`. Rules:
25
+
26
+ - `public_route_slug` stays **required** either way: it is the campaign
27
+ identity and the `_site/<public_route_slug>/` built-output directory name.
28
+ `route_root` describes the *served* path shape only.
29
+ - When present, `route_root` must be `"/"` or `"/<public_route_slug>/"`; any
30
+ other prefix is a doctor blocker (`campaign.route_root`) because it would
31
+ contradict the slug identity the built-output checks root on. `qa run`
32
+ reads the packet by the same rule: a value doctor blocks never roots a QA
33
+ check either — QA audits the slug-prefixed default instead — so a
34
+ hand-edited packet cannot pass QA at a root doctor refuses.
35
+ - Doctor's routing-meta checks (`routing_meta.runtime_root`,
36
+ `sdk_hints.meta_tags.route_mismatch`) and route displays validate against the
37
+ declared route root instead of assuming slug-as-prefix, so a root-served
38
+ funnel's correct `/receipt`-style metas pass without waivers.
39
+ - The CampaignSpec may carry the same declaration at `campaign.route_root` (or
40
+ `spec_identity.route_root`); `prepare-build` copies it onto the packet and
41
+ defaults `live_url_path` to `/`.
42
+ - Two `sdk_hints.meta_tags` keys older Map exports still carry, `next-currency`
43
+ and `next-predictive-address`, are not read by the Campaign Cart SDK (the
44
+ list is `src/sdk-meta-tags.mjs`; QA reads the same one). Doctor never
45
+ requires them from the built page: a spec that lists one gets a single
46
+ advisory warning per page, `sdk_hints.meta_tags.ignored_by_sdk`, naming the
47
+ key and the reason (`remove from the Map's page hints; the SDK does not read
48
+ it`), whether or not the tag rendered and before `_site/` exists. They are
49
+ never `sdk_hints.meta_tags.missing` and never appear in the pre-build
50
+ "CampaignSpec expects SDK meta tags (...)" list. The fix is an edit to the
51
+ Map's page hints, not to the build.
52
+
53
+ Page-kit also needs `campaign.store_url` for `_data/campaigns.json`. Additional Store Profile fields live under `campaign.store_*` as optional storefront/legal metadata because they are not Campaigns API data: the operator enters them, or `spec derive --from-store` derives them from the store (see "Deriving the spec from the repo" below).
54
+
55
+ ### Page Kit Store Profile checkpoint
56
+
57
+ Doctor compares the packet-local CampaignSpec to exactly nine governed fields
58
+ in `_data/campaigns.json[public_route_slug]`, in this order:
59
+ `store_name`, `store_url`, `store_terms`, `store_privacy`, `store_contact`,
60
+ `store_returns`, `store_shipping`, `store_phone`, and `store_phone_tel`.
61
+ Before scaffold, a missing target entry is `not_applicable`; once setup or
62
+ assembly is terminal, or the target output already exists, missing or malformed
63
+ target evidence is a non-waivable blocker. Target-only values remain warnings.
64
+ Mismatches, missing required target values, and known demo residue block.
65
+ Demo residue (a `demo.29next.com` URL or the demo phone number still in the
66
+ target) is never waivable: the gate names the residue fields, offers no waive
67
+ command for them, and `checkpoint waive` refuses with those fields until the
68
+ values are replaced.
69
+
70
+ The repair is a command. A fresh page-kit scaffold (`campaign-init`) seeds the
71
+ route's entry with the starter family's demo profile and pin, so this gate and
72
+ the SDK-version gate below block on every first run; the values that clear
73
+ them already exist in the CampaignSpec, and doctor and `next` print the
74
+ reconcile as the gate's `repair_target` action:
75
+
76
+ ```bash
77
+ campaigns-os page-kit sync --packet campaign-runtime.build.json [--dry-run] [--json]
78
+ ```
79
+
80
+ The CampaignSpec is the authority for the Store Profile: those values are
81
+ authored in the Map, never in the repo, so `page-kit sync` writes the nine
82
+ fields the spec carries (`campaign.store_*`) unconditionally. The SDK pin is
83
+ different. On an existing campaign the repo pin moves first and the Map/spec
84
+ is stale until someone re-saves it, so a spec → repo write would undo a bump
85
+ silently; sync therefore **seeds** `sdk_version` (`global_config.sdk_version`,
86
+ or the `runtime.sdk_version` alias when the canonical key is absent): it
87
+ writes the pin while the entry is still in scaffold state (the starter demo
88
+ store profile is still in it) or when the target pin is older than the
89
+ spec's, and refuses to move a configured campaign's pin backwards
90
+ (`not_synced`, reason `target_newer`, naming both versions and pointing at
91
+ re-saving the Map). The repo pin is the authority for what ships and the Map
92
+ field is a build hint, so that state is a doctor warning, not a blocker (see
93
+ the SDK version checkpoint below). Both go into `_data/campaigns.json[public_route_slug]`,
94
+ prints a field-by-field before/after diff, and touches nothing else: a
95
+ governed field the spec does not carry is left as it is (doctor's
96
+ `target_only` warning still applies), non-governed keys keep their values and
97
+ order, other routes and other files are not written. The file is edited in
98
+ place and re-serialized with its own top-level indentation, line ending and
99
+ trailing newline; when that round trip would not have reproduced the file
100
+ byte for byte (a minified file, mixed indentation), a
101
+ `page_kit.sync.file_reformatted` warning says so, because the printed diff
102
+ covers only the governed fields. `--dry-run` prints the same diff without
103
+ writing. Exit 0 on success (including a no-op re-run); exit 2 with
104
+ `page_kit.sync.*` error codes and nothing written when the packet cannot be
105
+ read, the target entry or the spec is missing or not an object, the spec
106
+ identifies another campaign (`spec_identity.public_route_slug` or `map_id`
107
+ disagreeing with the packet: `page_kit.sync.spec_identity_mismatch`), or the
108
+ resolved `_data/campaigns.json` lies outside the target repo through a symlink
109
+ (`page_kit.sync.target_escapes_repo`).
110
+
111
+ The spec is the authority, but the target is made authoritative only from a
112
+ usable spec value. States the target cannot be made authoritative for are
113
+ reported as `not_synced` with a reason, never written, and the run's status
114
+ is `partial` (exit 0, since the writes that could happen did; doctor will
115
+ still block): a conflicting or non-released spec pin; a spec field of the
116
+ wrong type (`spec_invalid_type`, doctor's own blocker); a URL field that is
117
+ not an http(s) URL or a `store_phone_tel` that is not a `tel:` URI of digits,
118
+ spaces, dashes, parens and dots (templates put both into `href` attributes,
119
+ where escaping does not neutralize another scheme); a value with control
120
+ characters; the starter demo value itself in the spec; and starter demo
121
+ residue in a governed field the spec does not carry (doctor blocks on that
122
+ residue without a waiver, and sync has no spec value to write over it). For
123
+ every one of those the gate's `repair_target` action is an edit naming the
124
+ spec field, not the sync command, so `next` never loops on a repair that
125
+ cannot make progress. Fix the spec, then sync again. (`store_contact` may
126
+ also be a `mailto:` address, the one non-http value templates render as a
127
+ contact link.) A gate under an active named-human waiver is a human decision
128
+ sync does not reverse: its fields are reported `not_synced` with reason
129
+ `waived` naming who waived, and the target stays as the waiver accepted it
130
+ until the waiver is withdrawn from the Assembly Report. Sync reads the report
131
+ doctor would (the one the Build Context binds, or `--report <path>`, which
132
+ doctor appends to the printed command when it inspected a non-default
133
+ report); unknown flags are rejected rather than ignored, so a mistyped
134
+ `--dry-run` cannot become a write. After a write the retained doctor
135
+ snapshot is marked stale; when the report records a terminal build, a
136
+ `page_kit.sync.build_stale` warning says the rendered `_site/` was built
137
+ from the old entry and points at the rebuild, because doctor's page-kit gates
138
+ read `_data/campaigns.json`, not the built output. Re-run `doctor` and both
139
+ gates report `pass` without a waiver.
140
+
141
+ An intentional, evidence-backed mismatch or missing value may be accepted with
142
+ the first gate in the staged checkpoint registry:
143
+
144
+ ```bash
145
+ campaigns-os checkpoint waive \
146
+ --packet campaign-runtime.build.json \
147
+ --gate page_kit.store_profile \
148
+ --reason "<why correction is intentionally deferred>" \
149
+ --waived-by "<named human>" \
150
+ --review-condition "<specific re-evaluation trigger>"
151
+ ```
152
+
153
+ Use `--expires-at <canonical-ISO-timestamp>` instead of, or alongside,
154
+ `--review-condition`; at least one bound is required and an expiry must be later
155
+ than `waived_at`. The decision is appended to the Assembly Report's top-level
156
+ `waivers[]` and fingerprints the exact slug, relative target path, and governed
157
+ discrepant field/value set. Stale, foreign, expired, or malformed records are
158
+ inert. A current waiver produces checkpoint status `waived` and doctor/next
159
+ readiness `ready_with_waivers`; it never becomes a clean pass. Raw arbitrary
160
+ campaign entry fields are validation-private and never belong in doctor, next,
161
+ sidecar, or QA evidence. The same boundary applies to waiver history: public
162
+ gate/readback/QA output whitelists the active decision's scope, current safe
163
+ subject, fingerprint, attribution, timestamps, and bound, while inert history
164
+ is exposed only as stale/foreign/malformed/expired counts. Raw report records
165
+ remain private to evaluation. Once correction removes all blocker fields,
166
+ waiver history is not evaluated or surfaced as an inert warning.
167
+
168
+ ### Page Kit SDK version checkpoint
169
+
170
+ The second registered checkpoint requires a canonical released semantic version
171
+ in CampaignSpec and a released target pin in
172
+ `_data/campaigns.json[public_route_slug].sdk_version`. CampaignSpec's
173
+ `global_config.sdk_version` is canonical, with `runtime.sdk_version` as an
174
+ accepted alias; declaring both is valid only when their released
175
+ versions are equal. Missing, malformed, present-but-empty, non-string,
176
+ prerelease, non-canonical, or conflicting dual declarations are non-waivable.
177
+ Missing or invalid target evidence is also non-waivable.
178
+
179
+ The two pins have a direction of authority. The repo pin is the version the
180
+ funnel serves, so it is the only value a bump can be proven against; the spec
181
+ field is a build hint whose job is to seed a fresh scaffold. The gate compares
182
+ them accordingly:
183
+
184
+ - **Equal** — pass.
185
+ - **Target newer than the spec, both released, entry configured** (the
186
+ starter demo store profile is gone) — a completed bump the Map has not been
187
+ re-saved for. Doctor passes the gate with the `page_kit.sdk_version.repo_newer`
188
+ warning and a ready line naming what ships; QA projects it as a `warn`
189
+ assertion; `next` does not stop. The gate's `advisory_actions` carry one
190
+ `refresh_spec` command, `campaigns-os spec derive --packet <packet>`
191
+ (below), which writes the repo pin into the spec; with `--write-map` it
192
+ also records the pin in the Map's Build hints field (Campaign Cart SDK
193
+ version), which is otherwise re-saved by hand to make the exported spec
194
+ stop reading stale. Nothing in the repo needs to change, and there is
195
+ nothing to waive.
196
+ - **Target behind the spec, or still the scaffold's seeded pin beside the demo
197
+ store profile** — blocked, repaired by `page-kit sync` (below) or waived.
198
+ - **Target pin not a released version** — blocked, non-waivable, whatever the
199
+ spec says.
200
+
201
+ Only the blocked mismatch between two valid released versions has a waiver
202
+ lane, and the decision fingerprints that exact expected/observed pair:
203
+
204
+ ```bash
205
+ campaigns-os checkpoint waive \
206
+ --packet campaign-runtime.build.json \
207
+ --gate page_kit.sdk_version \
208
+ --reason "<why this exact target pin is intentional>" \
209
+ --waived-by "<named human>" \
210
+ --review-condition "<specific re-evaluation trigger>"
211
+ ```
212
+
213
+ Changing either version makes the decision stale. The same named-human,
214
+ bounded-decision, visibility, and privacy rules described for Store Profile
215
+ apply. A target pin that is missing, malformed, or behind a valid spec pin
216
+ (or still the scaffold's seeded pin beside the demo store profile) is repaired
217
+ by the same `campaigns-os page-kit sync --packet <campaign-runtime.build.json>`
218
+ described above, which doctor and `next` print as the gate's `repair_target`
219
+ action. A configured campaign whose pin is newer than the spec's is the
220
+ advisory case above: no required action, and sync never moves that pin
221
+ backwards; `spec derive` is the command that closes it from the spec side.
222
+
223
+ ### Deriving the spec from the repo (`spec derive`)
224
+
225
+ Every CampaignSpec field has a class: **authored** (a human writes it: offers,
226
+ funnel shape, copy intent), **mirrored** (pulled from the Campaigns API or the
227
+ store: packages, prices, shipping) or **derived** (the repo or the store
228
+ already states it: the SDK pin, page routes, the store profile, analytics
229
+ ids). The table is on #432. Derived fields are generated, never typed, and
230
+ this command generates the repo-derived ones so doctor compares generated
231
+ against generated instead of refereeing a hand-typed value against the repo:
232
+
233
+ ```bash
234
+ campaigns-os spec derive --packet campaign-runtime.build.json [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>]
235
+ ```
236
+
237
+ By default it reads the target repo only (no network unless `--from-store` or
238
+ `--write-map`, below) and writes into the packet's local spec (`spec.local_path`):
239
+
240
+ | Spec field | Repo authority |
241
+ |---|---|
242
+ | `global_config.sdk_version`, and `runtime.sdk_version` when the spec declares the alias (so the two never conflict) | `_data/campaigns.json[public_route_slug].sdk_version` |
243
+ | `funnels[].pages[].page_url` (the legacy `funnel_pages[].page_url` mirror, when present, is reconciled to the same route on every run) | the page tree under `assembly.output_dir` (default `src/<public_route_slug>/`), read by page-kit's own rule: the file's basename alone (`checkout.html` → `checkout/` wherever it sits; `index.html` → the entry route; a nested `index.html` collides with the root and is not read), or a frontmatter `permalink` in the `/<slug>/<route>/` form prepare-build writes (page-kit serves a permalink verbatim, so any other spelling is a repo defect, refused) |
244
+ | `analytics.providers.gtm.containerId` | `_data/campaigns.json[public_route_slug].gtm_id` |
245
+ | `analytics.providers.facebook.pixelId` | `_data/campaigns.json[public_route_slug].fb_pixel_id` |
246
+
247
+ Nothing else is written: not the store profile (store-derived; see
248
+ `--from-store` below), not any authored or mirrored field, not the packet,
249
+ not the repo. A
250
+ derived field the spec carries with a different, authored-looking value is
251
+ overwritten, and the printed `before -> after` line shows it: that is the
252
+ class doing its job. A block the spec lacks is created (`global_config`,
253
+ `analytics.providers.gtm` as `{ "enabled": true, "containerId": … }`); an
254
+ array element is never invented.
255
+
256
+ Each page is bound to one file: the packet's own projection first
257
+ (`source_html.pages[].page_kit.target_path`, the file the build stage wrote
258
+ for that page id), else a file whose route equals the page's current route,
259
+ whose terminal segment equals it, or whose filename is the page id. The
260
+ derived route is compared the way prepare-build projects a route
261
+ (normalized, slug prefix stripped), so a spelling that already resolves to
262
+ the tree's route (`checkout`, `/<slug>/checkout/`) is not a change, and a
263
+ value nested differently from the tree (`offers/upsell/` against
264
+ `upsell.html`) is. A routing hint in `sdk_hints.meta_tags` (`next-success-url`,
265
+ `next-upsell-accept-url`, `next-upsell-decline-url`) that no longer matches
266
+ the derived route of the page it names is reported as
267
+ `spec.derive.routing_hint_stale` on every run until the Map is re-saved;
268
+ hints are a Map projection the editor regenerates and are not rewritten.
269
+
270
+ What the repo cannot state is reported as `not_derived[]` with a reason and
271
+ the status is `partial` (exit 0; the fields it could derive are written):
272
+
273
+ | Reason | Meaning |
274
+ |---|---|
275
+ | `scaffold_seed` | the entry still carries the starter demo store profile, so its pin is the starter's seed, not a version anyone chose; `page-kit sync` seeds the pin from the spec in that state |
276
+ | `target_missing`, `target_invalid` | the entry has no `sdk_version`, or it is not a released `MAJOR.MINOR.PATCH`; for an analytics id, the value is not a GTM container id / a digits-only pixel id; for a route, the file's permalink is not in the `/<slug>/<route>/` form (no slug prefix, another prefix, `.html`, `..`, a control character), reported with the URL page-kit would serve |
277
+ | `waived` | an active named-human `page_kit.sdk_version` waiver covers the exact pair; derive leaves the spec as the waiver accepted it |
278
+ | `spec_ahead` | a released pin the spec declares (canonical or alias) is ahead of the repo pin: the state doctor blocks on with `page-kit sync` as its repair (#413); one command owns it, so derive never moves a spec pin backwards |
279
+ | `page_tree_missing`, `page_file_not_found`, `page_file_ambiguous` | no page tree, no file binds to the page, or more than one does |
280
+ | `entry_route_undeclared` | the page binds to the top-level `index.html` (the entry route, `""`) but is not flagged `is_entry`; doctor honours an empty `page_url` only on the entry page, so the flag is asked for in the Map rather than the route written |
281
+ | `waivers_unknown` | the Assembly Report could not be read, so a named-human `page_kit.sdk_version` waiver cannot be ruled out; the pin waits, routes and ids still derive |
282
+ | `page_id_duplicate` | the page id appears more than once; no single route can be derived for it |
283
+ | `spec_container_invalid` | `global_config`, `runtime`, `analytics`, `analytics.providers` or `analytics.providers.<provider>` exists in the spec but is not an object; reported by the plan so `--dry-run` and the write agree |
284
+ | `target_empty` | the entry's `gtm_id` / `fb_pixel_id` is empty while the spec declares an id; an empty repo value never deletes a spec id |
285
+
286
+ A placeholder id (`GTM-XXXXXXX`, a run of one digit) is `target_invalid`:
287
+ writing it would declare an analytics contract QA then blocks on. A derived
288
+ field the entry does not carry at all is listed under `not_in_target[]` and
289
+ left as it is.
290
+
291
+ After a write, the sidecars that carry the spec's identity are re-bound. The
292
+ Build Context's `spec.hash` / `spec.material_hash` and the Assembly Report's
293
+ `identity.spec_hash` / `identity.spec_material_hash` move to the new spec
294
+ when they were bound to the one derive replaced and name that spec file
295
+ (`rebound` on the result; two packets sharing a target repo never re-bind
296
+ each other's sidecars);
297
+ QA's verdict and `bundle check` correlate against the material hash, so this
298
+ is what keeps a derive-then-QA run conformant. A sidecar already carrying
299
+ another identity is left alone with a `spec.derive.identity_not_rebound`
300
+ warning naming `prepare-build`. A derived route change also warns
301
+ `spec.derive.projection_stale`: the packet's page-kit projection
302
+ (`source_html.pages[].page_kit`) and the Build Context page map were prepared
303
+ from the old routes, and `prepare-build` (or `start`) regenerates them. A
304
+ provider block derive creates warns `spec.derive.analytics_block_created`:
305
+ the spec then declares an analytics contract QA gates on. `--dry-run` carries
306
+ the same `file_reformatted`, `projection_stale` and `build_stale` warnings,
307
+ phrased as what a write would do.
308
+
309
+ Write discipline is `page-kit sync`'s: one read serves the plan and the
310
+ write; the file is edited in place and re-serialized with its own top-level
311
+ indentation, line ending and trailing newline (`spec.derive.file_reformatted`
312
+ when that round trip would not reproduce the file byte for byte); staged
313
+ through a temp file created with the spec's own mode bits and renamed over it,
314
+ after re-reading the spec and refusing (`spec.derive.spec_changed_underneath`,
315
+ exit 2) when it changed since the single read; written only at the path
316
+ the spec resolves to, which must lie inside the spec's own directory or the
317
+ target repo (`spec.derive.spec_escapes_boundary` otherwise, so a symlinked
318
+ `spec.local_path` cannot redirect the write); the retained doctor sidecar is
319
+ marked stale after a write, and a terminal build gets a
320
+ `spec.derive.build_stale` warning naming the rebuild (doctor does not
321
+ fingerprint the spec itself; the re-bound sidecar identity is what QA reads). `--dry-run` prints the
322
+ same diff and writes nothing; unknown flags and a valued `--dry-run` are
323
+ rejected. Exit 2 with `spec.derive.*` error codes and nothing written when the
324
+ packet cannot be read, `spec.local_path` is absent or not a file, the spec is
325
+ not a JSON object, the spec identifies another campaign
326
+ (`spec.derive.spec_identity_mismatch`), or the target entry is missing
327
+ (`spec.derive.entry_missing`; scaffold first).
328
+
329
+ #### Deriving the store profile from the store (`--from-store`)
330
+
331
+ The nine `campaign.store_*` Store Profile fields are derived too, and their
332
+ authority is the store: `page-kit sync` writes them spec → repo, and this is
333
+ the generator for the store → spec half. It needs a credential and the
334
+ network, which the default run never touches, so it is opt-in:
335
+
336
+ ```bash
337
+ campaigns-os spec derive --packet campaign-runtime.build.json --from-store <subdomain> [--store-token-source env:<VAR>] [--dry-run] [--json]
338
+ ```
339
+
340
+ `<subdomain>` is the store's `<store>.29next.store` subdomain (the Admin API
341
+ lives at `https://<subdomain>.29next.store/api/admin/`). The read token is
342
+ taken from the environment, never from the command line: from
343
+ `<SUBDOMAIN>_ADMIN_TOKEN` (upper-cased, dashes as underscores) by default,
344
+ or from the variable `--store-token-source env:<VAR>` names. An Admin API
345
+ access token with the `store:read` and `content:read` scopes (Settings >
346
+ API Access) is enough; the token is sent as a bearer and appears nowhere in
347
+ the output, which names the variable instead (a value that is not one line
348
+ of printable ASCII is refused unsent, `spec.derive.store_credential_invalid`,
349
+ and a transport error that quotes a header is redacted). The store is only
350
+ read.
351
+
352
+ | Spec field | Store authority |
353
+ |---|---|
354
+ | `campaign.store_name` | `GET /store/` `name` (Admin API version `2024-04-01`) |
355
+ | `campaign.store_url` | `GET /store/` `primary_domain`, as `https://<primary_domain>` |
356
+ | `campaign.store_phone` | `GET /store/` `contact_address.phone_number`, verbatim |
357
+ | `campaign.store_phone_tel` | the same phone as a `tel:` URI (digits, a leading `+` kept), only when the display phone is one plain number: an extension, a second number or a vanity word would fold into the digits and dial something else, so those leave the field not derived (`target_invalid`) |
358
+ | `campaign.store_terms`, `store_privacy`, `store_contact`, `store_returns`, `store_shipping` | `GET /pages/` (Admin API version `unstable`, followed cursor by cursor under the store's own pages endpoint): the one storefront page that carries the policy, as `https://<primary_domain>/<slug>/`, which is where the storefront serves it. A page whose slug is one of the policy's conventional slugs (`terms`, `terms-of-service`, `privacy-policy`, `contact-us`, `return-policy`, `shipping-policy`, `shipping-returns`, …) binds first; only when no page has a conventional slug does the wider match by slug or title words apply (terms/tos/conditions; privacy; contact; return(s)/refund(s); shipping/delivery), so a "free shipping" promo page never outranks the policy. One page may carry two policies (`shipping-returns`) |
359
+
360
+ Rows join the same `before -> after` diff in the Store Profile's field
361
+ order, each with its store source, and are compared NFC-normalized and
362
+ trimmed as `page-kit sync` compares them, plus one leniency of derive's own:
363
+ URL fields compare without a trailing slash, so a spec that carries
364
+ `https://x.example/` is left alone when the store says `https://x.example`
365
+ rather than churned (sync then writes the spec's spelling into the repo as
366
+ it is). Every value
367
+ passes the Store Profile shape rule before it is planned: the starter demo
368
+ store's URL or phone, a non-http(s) URL, a malformed `tel:` or a control
369
+ character is `target_invalid`, never written. A field the store cannot state
370
+ is `not_derived[]` and **the spec's value is left as it is** (a store never
371
+ empties a spec field): `store_field_missing` (an empty name, domain or
372
+ phone), `store_domain_missing` (no primary domain, so no page URL can be
373
+ formed), `store_page_not_found`, `store_page_ambiguous` (several pages read
374
+ as the policy; the slugs are named), `store_pages_unavailable` (the pages
375
+ endpoint failed, with the reason: a token without `content:read`, a version
376
+ that does not serve `/pages/`, a body that is not the page list, a cursor
377
+ outside the store's pages endpoint that was not followed) and
378
+ `store_pages_truncated` (more pages than ten requests or two thousand rows
379
+ return). A slug that is not one honest path segment (a separator, `.` or
380
+ `..`, malformed text) is `target_invalid`. When the store's primary domain is not the host the spec's
381
+ `store_url` named, `spec.derive.store_domain_changed` says so: either the
382
+ spec was stale and the diff is the correction, or `--from-store` names
383
+ another merchant's store and the spec should be restored.
384
+
385
+ The result carries a `store` block (`subdomain`, `admin_api`,
386
+ `token_source`, `store_read`, `pages_read`, `primary_domain`), and the text
387
+ output a `Store:` line. After a write that moved a store field, `next` is
388
+ `page-kit sync` first (doctor's `page_kit.store_profile` gate now sees the
389
+ spec ahead of the repo and names sync as its repair), then doctor. A store
390
+ that cannot be read is a refusal with nothing written, repo fields included,
391
+ exit 2: `spec.derive.store_credential_missing` (the variable is unset or
392
+ empty), `store_unauthorized` (401/403), `store_not_found` (404: no store at
393
+ that subdomain), `store_unreachable` (transport, timeout, 5xx) or
394
+ `store_response_invalid`. Local preconditions (packet, spec, target entry, spec boundary, page tree)
395
+ are checked before the store is contacted, and a packet that names another
396
+ spec or route by the time the read returns is refused
397
+ (`spec.derive.packet_changed_underneath`). `--store-token-source` without
398
+ `--from-store`, a subdomain that is not one (a URL, a path), or a token
399
+ source that is not `env:<VAR>` is rejected before anything is read.
400
+
401
+ #### Recording the pin in the Map (`--write-map`)
402
+
403
+ The local derive fixes the exported spec; the Map itself still shows the old
404
+ pin until someone re-saves Build hints, so anyone opening the Map or a fresh
405
+ export reads stale. `--write-map` closes that half (#415): after the local
406
+ write, the pin the plan derived is recorded into the saved Map's Build hints
407
+ field (Campaign Cart SDK version) through the proxy Worker, with the same
408
+ direction of authority as everything else on this gate: the write goes
409
+ forward or not at all.
410
+
411
+ ```bash
412
+ campaigns-os spec derive --packet campaign-runtime.build.json --write-map [--dry-run] [--proxy-base <url>]
413
+ ```
414
+
415
+ The Map named by the packet's `spec.map_id` is read back (`GET
416
+ /api/spec/<map-id>`) and re-stated with exactly the pin fields moved
417
+ (`global_config.sdk_version`, and `runtime.sdk_version` only when the Map
418
+ already declares the alias): every other field is the Map's own read-back,
419
+ never the local spec, so an authored field is not rewritten from a local copy
420
+ and the routes or analytics ids derive wrote locally do not travel. The `PUT
421
+ /api/maps/<map-id>` carries the packet's Campaigns API key as
422
+ `X-Campaign-Key` (the receiver refuses a key that is not the Map's; the key
423
+ is the public-by-design one the packet, its local spec or the declared env
424
+ source already holds) and the Map's `spec_hash` as `X-Spec-Hash`, so a save
425
+ that landed in between is a conflict, not an overwrite. The proxy base is the
426
+ canonical `https://campaign-map.nextcommerce.com` unless `--proxy-base` names
427
+ another; it must be https, or a loopback host over http (allowed for a local
428
+ receiver, with a stderr warning that the key travels in clear).
429
+
430
+ The decision, reported on the result's `map` object and as one text line:
431
+
432
+ | `map.status` | Meaning |
433
+ |---|---|
434
+ | `written` | the Map declared no pin, or one behind the repo pin; it now records the repo pin (`map.spec_identity.before` / `.after` carry the Map's `spec_hash` and `saved_at` either side) |
435
+ | `unchanged` | the Map already records the repo pin; nothing sent |
436
+ | `would_write` | `--dry-run`: the Map was read and the write previewed; nothing sent |
437
+ | `refused` | a warning, exit 0, the local derive stands: `ahead` (the Map pin is newer than the repo pin — a bump the repo never received, doctor's blocked state and `page-kit sync`'s repair; the Map is never moved backwards) or `pin_unreadable` (the Map's pin is not a released version, or two declarations disagree; a value the rule cannot order is not overwritten silently) |
438
+ | `skipped` | the pin was not derived (`pin_<reason>`, the `not_derived` reason: a scaffold's seed, a waiver, `spec_ahead`, …) or the local derive was blocked; nothing was read or sent |
439
+ | `failed` | an error, exit 2, the local derive stands: `key_missing` (no Campaigns API key anywhere), `key_mismatch` (403), `not_found` (404), `changed_underneath` (409: derive again against the current save), `rejected` (the proxy's spec validation refused the re-stated Map: re-save it in the builder first), `proxy_base_insecure`, `network_error`, `http_error`, `response_invalid` (an answer that says neither yes nor no: read the Map back before deriving again) |
440
+
441
+ A write is traceable from the campaign's own record: one line is appended to
442
+ the Assembly Report's `evidence[]` (`Map write-back: global_config.sdk_version
443
+ <before> -> <after> on Map <id> at <time> via spec derive --write-map (Map
444
+ spec_hash <before> -> <after>)`), the retained doctor sidecar is marked stale
445
+ by `spec derive --write-map`, and the run's lifecycle journal carries the
446
+ command with its argv shape, so the Run Record (which references the report by
447
+ hash) shows both that the write ran and what it changed. A report that does
448
+ not exist yet (a derive before `prepare-build`) leaves a
449
+ `spec.derive.map_not_recorded` warning carrying the same line; a report that
450
+ took the line while the doctor stamp failed leaves
451
+ `spec.derive.map_doctor_sidecar_not_marked` instead, and one that could not be
452
+ read back after the failure leaves `spec.derive.map_recorded_status_unknown`
453
+ (`map.recorded: "unknown"`) rather than a claim either way. A 403 on the read
454
+ is `key_mismatch`, as on the write. Without
455
+ `--write-map` nothing is read from or sent to the Map; `--proxy-base` is
456
+ refused on its own.
457
+
458
+ ### Polish hidden eager-media checkpoint
459
+
460
+ The third registered checkpoint is package-owned page-load evidence recorded at
461
+ `stages.polish.evidence.visual_review.page_load`. Install the package-owned
462
+ browser, serve the current build, and run this producer before marking Polish
463
+ complete, deploying, or starting QA:
464
+
465
+ ```bash
466
+ npm run qa:install-browser
467
+ campaigns-os polish capture \
468
+ --packet campaign-runtime.build.json \
469
+ --base-url <served-current-build-url>
470
+ ```
471
+
472
+ The producer derives every mapped, non-skipped route from the packet and
473
+ captures desktop `1440x1200` and mobile `390x844`. It blocks when a
474
+ computed-hidden `video` or `audio` element transfers strictly more than
475
+ `1,048,576` bytes, unless its content attribute is exactly
476
+ ASCII-case-insensitive `none` or `metadata`. Evidence exactly at the byte
477
+ threshold passes. The command owns the versioned capture format and its
478
+ integrity binding; never hand-author or copy `page_load`.
479
+
480
+ The operator supplies the URL of the current served output. The evidence binds
481
+ to packet/report authority, but the URL itself is not cryptographic proof that
482
+ the server is hosting those exact build bytes. The durable field map, bounded
483
+ measurement semantics, and attachment race boundary are documented in
484
+ [Polish evidence](./polish-evidence.md#durable-page_load-field-map).
485
+
486
+ Missing, malformed, stale, incomplete, or contradictory measurement evidence
487
+ is nonwaivable. A complete real finding may receive an exact named-human
488
+ decision:
489
+
490
+ ```bash
491
+ campaigns-os checkpoint waive \
492
+ --packet campaign-runtime.build.json \
493
+ --gate polish.hidden_eager_media \
494
+ --reason "<why this exact finding is accepted>" \
495
+ --waived-by "<named human>" \
496
+ --review-condition "<specific re-evaluation trigger>"
497
+ ```
498
+
499
+ The decision binds the build fingerprint, campaign slug, route scope, routes,
500
+ fixed viewports, and stable finding state. Any change makes it inert. Doctor and
501
+ `next` report `ready_with_waivers`; QA reports `ready_with_exceptions` and keeps
502
+ the warning visible.
503
+
504
+ QA evaluates all three registered gates from one packet/spec/target/report
505
+ snapshot; a waiver for one never hides a blocker in another. See
506
+ [QA checkpoint preflight](./qa-and-test-orders.md#packet-local-checkpoint-preflight)
507
+ for the downstream runtime boundary.
508
+
509
+ Every gate that still owes work carries `required_actions[]` — the exact repair
510
+ command or manual step, plus the waiver command. `campaigns-os doctor` prints
511
+ the same actions in its text report, under a `Required actions:` block below the
512
+ errors and warnings, so an operator reading stdout gets the remediation without
513
+ re-running with `--json`.
514
+
515
+ ### Built-output campaign identity gate (`built_output.campaign_identity`)
516
+
517
+ Every doctor run that sees built output (the packet path and `doctor --built`
518
+ alike) checks that the pages agree about which campaign they belong to. The
519
+ SDK reads three identity signals per page and reconciles nothing across
520
+ pages: the API key (`<meta name="next-api-key">` beats `window.nextConfig.apiKey`,
521
+ whether inline or in the `config.js` the page loads), the `next-funnel` meta,
522
+ and any `setAttribution({ funnel })` call. A page copied from another funnel
523
+ that still carries the other campaign's key, tag, or attribution call binds,
524
+ builds, and renders without complaint, and creates or attributes the order
525
+ against the wrong campaign. It has shipped twice.
526
+
527
+ The gate blocks (not waivable — two identities on one funnel cannot both be
528
+ intended) when:
529
+
530
+ - any two observed API keys differ, from any source on any page, including a
531
+ page whose meta names one key while its `config.js` names another
532
+ (`built_output.campaign_identity.api_key_drift`);
533
+ - `next-funnel` differs across pages (`…funnel_drift`), or, once any page
534
+ carries the tag, a page that declares `next-page-type` has no `next-funnel`
535
+ (`…funnel_missing`; a campaign that tags no page at all is consistent, and
536
+ the platform fills the campaign name when the tag is absent);
537
+ - a `setAttribution({ funnel })` string disagrees with the `next-funnel` of the
538
+ page that calls it, or with the campaign's tag when that page has none
539
+ (`…attribution_drift`).
540
+
541
+ One error per finding; each names the two files and the two values, so the
542
+ repair is a one-line edit. Pages whose route contains a `-backup-` or `-old-`
543
+ segment are parked copies: skipped and listed on the gate as `pages_skipped`,
544
+ never scanned. Presence is not asserted: a campaign whose pages carry no key
545
+ at all, or no `setAttribution` anywhere, passes on the funnel tag alone.
546
+ `checkpoint waive` does not register this gate; the repair is the only route.
547
+
548
+ The gate's evidence lands beside the other checkpoint gates at
549
+ `derived.checkpoint_gates[]` (`id: built_output.campaign_identity`, status
550
+ `pass` | `blocked` | `not_applicable`, `identity: { api_key, api_key_source,
551
+ funnel }`, `findings[]`, `pages_scanned`, `pages_skipped`). It is proven to
552
+ pass on the canonical rendered output of every certified starter family
553
+ (`fixtures/certified-families/`), the reachability bar every static
554
+ built-output gate now carries.
555
+
556
+ ### Built-output SDK markup gate (`built_output.sdk_markup`)
557
+
558
+ Every doctor run that sees built output also runs the static SDK markup
559
+ family: six shapes of `data-next-*` markup that the Campaign Cart SDK binds
560
+ without complaint and that then either do nothing (a field that never reaches
561
+ the order, a button that never enables) or write the cart twice. They sit
562
+ beside `built_output.upsell_selector_scope`, which is the same kind of check
563
+ for one shape. The codes are the ones a partner Campaign Cart kit used, kept so
564
+ the two vocabularies line up; each doctor issue is `built_output.sdk_markup.`
565
+ plus the code lower-cased, and its message leads with the code.
566
+
567
+ Blockers (not waivable — the markup provably does not do what it says):
568
+
569
+ - `SWAP_WITH_ADD_TO_CART` — a bundle selector in swap mode (explicit
570
+ `data-next-selection-mode="swap"`, or the SDK default when the attribute is
571
+ absent) with an `add-to-cart` button linked to it by `data-next-selector-id`.
572
+ Both write the cart. An upsell-context selector is exempt: it is select mode
573
+ by construction.
574
+ - `CHECKOUT_NOT_FORM` — `data-next-checkout` on an element that is not `<form>`.
575
+ - `WRONG_FIELD_NAME` — `data-next-checkout-field` with a value the SDK does not
576
+ map. The set is vendored from the SDK at a named tag
577
+ (`src/sdk-attribute-index.mjs`, currently v0.4.38: `email`, `fname`, `lname`,
578
+ `phone`, `address1`, `address2`, `city`, `province`, `postal`, `country`,
579
+ `payment-method`, `accepts_marketing`, `cc-number`, `cc-month`, `cc-year`,
580
+ `exp-month`, `exp-year`, `cvv`, the legacy `card-*` spellings, and any
581
+ `billing-` prefixed name). The message names the SDK spelling for the usual
582
+ offenders (`firstName` → `fname`, `zip` → `postal`).
583
+ - `MISSING_SELECTOR_ID_MATCH` — an `add-to-cart` button whose
584
+ `data-next-selector-id` names no selector on the page (an element that is a
585
+ bundle, package, cart or upsell selector; another element echoing the id
586
+ does not count). One finding per dead id, however many buttons link to it.
587
+
588
+ Warnings (advisory):
589
+
590
+ - `DOUBLE_SELECTED` — more than one `data-next-selected="true"` card inside one
591
+ selector.
592
+ - `TEMPLATE_DOUBLE_BRACE` — `{{` inside an SDK-owned `<template>`: the direct
593
+ child of a container the SDK clones from (`data-next-cart-summary`,
594
+ `data-summary-lines`, `data-next-discounts`, `data-next-bundle-selector`,
595
+ `data-next-bundle-slots`, `data-next-package-selector`,
596
+ `data-next-package-toggle`), or one a `*-template-id` attribute points at.
597
+ SDK tokens are single-brace; a template nothing in the SDK reads, including
598
+ a vendor template nested deeper inside SDK chrome, may use any syntax.
599
+
600
+ Information: `data-next-*` names the vendored attribute index does not list are
601
+ collected on the gate (`unknown_attributes[]`) and printed as one advisory ready
602
+ line, never as a warning. That is where an invented attribute such as
603
+ `data-next-coupon-input` shows up; it is information rather than a warning
604
+ because the certified templates carry a handful of their own `data-next-*`
605
+ hooks the SDK never reads.
606
+
607
+ Markup inside SDK templates is scanned too, since the SDK clones it into the
608
+ live DOM. A gate reports one disposition: while blockers stand, advisories
609
+ stay on the gate's `warned[]` and become doctor warnings only once the
610
+ blockers clear; the gate `reason` names the first five findings and a count. The gate's evidence lands beside the other checkpoint gates at
611
+ `derived.checkpoint_gates[]` (`id: built_output.sdk_markup`, status `pass` |
612
+ `blocked` | `not_applicable`, `findings[]` for blockers, `warned[]` for
613
+ advisories, `unknown_attributes[]`, `pages_scanned`,
614
+ `sdk_attribute_index_version`). Fixtures: `fixtures/sdk-markup/<code>/{bad,good}`.
615
+ It passes, with no advisory, on the canonical rendered output of every
616
+ certified starter family (`fixtures/certified-families/`).
617
+
618
+ > **Where does the source HTML come from?** See [docs/entry-points.md](./entry-points.md) for the five recognized entry points (template-stock, Figma-driven, AI-generated, hand-authored, mixed) and how each populates `source_html.pages[]` + `design_source`.
619
+
620
+ ## Artifact Locations
621
+
622
+ By default `campaigns-os start` writes into the target repo:
623
+
624
+ ```text
625
+ campaign-runtime.build.json
626
+ .campaign-runtime/build-context.json
627
+ .campaign-runtime/assembly-report.json
628
+ .campaign-runtime/doctor-output.json
629
+ .campaign-runtime/theme/theme-report.json
630
+ .campaign-runtime/input/campaign-build-brief.normalized.json
631
+ .campaign-runtime/input/design-source-package.json
632
+ ```
633
+
634
+ Those `.campaign-runtime/` paths are relative to the target repo
635
+ (`packet.assembly.target_repo`, resolved against the packet's directory) even
636
+ when `--out` keeps the packet somewhere else, and every stage — `doctor`
637
+ included — reads and writes them there. When `prepare-build --report-out`
638
+ puts the Assembly Report elsewhere, the Build Context records that path
639
+ (`report_path`, relative to the target repo) and `doctor`, `next`, `qa run`,
640
+ `qa waive` and the QA stage record follow it, so the report `next` reads is the
641
+ one QA writes into — provided the context's `packet_path` names that packet;
642
+ a context naming another packet binds nothing. `theme waive`, `checkpoint
643
+ waive`, `polish capture`,
644
+ `findings harvest`, `run-record` and `run status` act on the default location
645
+ unless `--report` names another. `run-record` keys its record on a `run_id`
646
+ resolved as `--run-id`, else the active run session, else the most recent Run
647
+ Record already on disk for this packet's campaign (re-emitted in place), else
648
+ a freshly minted id; `--new-run` mints on request and `--list` prints the ids
649
+ on disk without writing (see docs/workflow-findings-sidecar.md).
650
+
651
+ The packet's top-level `generated_at` (ISO-8601 UTC, `Z` suffix) is stamped by
652
+ `prepare-build` on every new packet. Downstream freshness — campaigns-agent's
653
+ readback staleness comparison and its multi-packet selection at the repository
654
+ root — reads this field, never file mtime. Packets generated before the field
655
+ existed remain schema-valid without it, but they cannot win freshness selection
656
+ and never satisfy a fresh-artifact readback on their own; regenerate rather
657
+ than hand-adding the field. Note that a committed artifact set always reads
658
+ stale to the readback once HEAD moves past it — freshness proof is a
659
+ regeneration at HEAD, not a property a commit can preserve.
660
+
661
+ Commit durable packet/context/report artifacts when they represent a real build handoff. The rest of `.campaign-runtime/` is machine-local and is not for the campaign repository: `run-session.json`, `command-lifecycle.jsonl`, `agent-deviations.jsonl`, `workflow-findings.jsonl`, `run-records/`, `fetched-specs/`, `polish-evidence/`, `evidence/`, and `*.log`/`*.tmp` are per-machine, append-only, or carry live URLs and absolute paths, and so are the full QA verdicts `qa run` writes under the target's `qa-output/`. `start`, `prepare-build`, `install-agent-context`, and `run start` write a managed ignore block for exactly that set into the target's `.gitignore` once (keyed on its marker line; edit the list beneath it freely). The readback bundle (`build-context.json`, `assembly-report.json`, `doctor-output.json`, `qa-verdict.json`), `input/`, `theme/`, `agent-context/`, and `setup-handoff.json` are deliberately not ignored. The Campaigns API key is a public, browser-side, domain-allowlisted key and may already be present in the local CampaignSpec as `campaign.campaigns_api_key`; do not duplicate it into the packet unless the spec is unavailable. Do not commit raw private API responses, backend secrets, or temporary media exports.
662
+
663
+ Packet-mode `doctor` is inspection-only by default and preserves retained evidence and active run journals, even when lifecycle capture is configured. Use `--write` to intentionally record fresh evidence; `--no-write` takes precedence. Inspection still reports current blockers and keeps the same exit status.
664
+
665
+ `campaigns-os doctor --packet <packet> --write` restates its outcome on the Assembly Report's `stages.doctor` (status, command, outputs, blockers, warnings, `checked_at`). A re-run that reaches the same outcome leaves the report's bytes unchanged rather than refreshing the timestamp alone, so a digest taken of the report — a Run Record's `assembly_report` sha256 — keeps verifying across repeated doctor runs; a changed outcome still rewrites the file.
666
+
667
+ `campaigns-os start` / `campaigns-os prepare-build` writes packet, context, report, and generated doctor-output paths as relative paths by default, including sibling CampaignSpec/source directories such as `../campaign-source`. `campaigns-os doctor` continues to accept older absolute-path packets; use `campaigns-os doctor --packet <packet> --write --strip-paths` when regenerating a commit-ready doctor output from an older packet. Committed handoff artifacts should not contain machine-local absolute paths unless no relative form is possible.
668
+
669
+ `start` / `prepare-build` also run the [Brand Theme Bridge](./brand-theme-bridge.md)
670
+ in `inspect_only` mode. The optional theme evidence lives in `context.theme`,
671
+ `report.theme`, and `.campaign-runtime/theme/theme-report.json`. The Build
672
+ Packet itself does not gain required theme fields in v0.
673
+
674
+ A campaign whose source carries no brand tokens has one more decision to make,
675
+ and it is due before QA rather than after it. With nothing to generate, the
676
+ theme gate passes (`theme_gate.nothing_generatable`) and no brand layer is
677
+ applied, so the commerce pages ship the starter family's own palette — and
678
+ browser QA, with the gate unwaived, runs the template-residue checks at blocker
679
+ severity, so `qa run` blocks on `template-residue:<page>:style:*` rows for the
680
+ starter call-to-action colour. That is deliberate on both sides: a passing gate
681
+ means "nothing could be generated", not "this palette was reviewed". Two lanes
682
+ clear it, and `campaigns-os next` names them from the build stage onward so the
683
+ choice is made before a blocked verdict forces it — for the families this
684
+ applies to. Palette residue is a certified-family check: it runs only where the
685
+ selected `template_family` has a brand contract listing both the starter colours
686
+ and the commerce selectors to inspect them on, so a `custom` or `undecided`
687
+ family produces no `template-residue:*:style:*` rows and `next` stays quiet
688
+ rather than asking for a waiver it does not need. Either record an explicit
689
+ operator waiver (`campaigns-os theme waive --packet <packet> --reason "<why the
690
+ starter palette is acceptable>" --waived-by "<named human>"`, optionally
691
+ `--expires-at <canonical ISO timestamp>`), which downgrades those rows to `warn`
692
+ (status and severity — never `fail`) and keeps the shipped palette visible in
693
+ the verdict; or hand-author the brand
694
+ layer — write `brand-theme.css`, list it after `next-core.css` in commerce-page
695
+ frontmatter styles, rebuild, and record `report.theme.status: applied` with
696
+ `load_order: after-next-core`. Nothing waives the gate on the operator's
697
+ behalf. See [Brand Theme Bridge](./brand-theme-bridge.md) for both lanes in
698
+ full.
699
+
700
+ `start` / `prepare-build` also accept `--brief <campaign-build-brief.yaml|json>`
701
+ and auto-discover `campaign-build-brief.yaml`, `.yml`, or `.json` from the
702
+ source root or target repo. When none is present, Campaigns OS creates a guided
703
+ draft at `.campaign-runtime/input/campaign-build-brief.normalized.json`.
704
+ See [Campaign Build Brief](./campaign-build-brief.md) for the schema and
705
+ prepared/guided behavior.
706
+
707
+ `start` / `prepare-build` also prepares the normalized Design Source Package at
708
+ `.campaign-runtime/input/design-source-package.json`. When that path is absent,
709
+ the command synthesizes and writes the package; when it exists, the command
710
+ validates it against the current campaign/page/source/template inputs and reuses
711
+ its exact bytes. It refuses stale or contradictory packages instead of silently
712
+ regenerating them. Its schema version is
713
+ `campaign-design-source-package/v0` (schema file:
714
+ `schemas/campaign-design-source-package.v0.schema.json`). The Build Packet,
715
+ Build Context, and Assembly Report reference that artifact by path, full artifact
716
+ hash, and material fingerprint instead of embedding it,
717
+ matching the normalized Build Brief handoff pattern. The full hash supports audit
718
+ and reproduction; the material fingerprint drives freshness gates. The package
719
+ includes a generated top-level `readiness` summary with
720
+ `status`, `blocking_reasons`, `gap_count`, `todo_count`, `waiver_count`, and
721
+ `generated_at`; detailed gaps, TODOs, and waivers remain authoritative.
722
+ See the dedicated [Design Source Package v0 guide](./design-source-package.md)
723
+ for the exact reference shape, material projection, emit/reuse/refusal boundary,
724
+ and lifecycle ownership contract. This section keeps the Build Packet handoff
725
+ context and does not replace that consumer guide.
726
+
727
+ `readiness.status` uses `pending`, `blocked`, `ready`, `ready_with_gaps`, or
728
+ `ready_with_waivers`, not `ready_with_warnings`. The package may include
729
+ free-form `notes`, but notes do not affect readiness; any concern that affects
730
+ whether Build or Polish can proceed must be typed as a gap, TODO, proposed
731
+ exception, or waiver. Source gaps and TODOs require `scope` and `applies_to`;
732
+ attach them to Surface Identity when possible. The package reserves a top-level
733
+ Surface Identity entry `campaign` with `kind: "campaign"`; use
734
+ `applies_to: ["campaign"]` for legitimate campaign-level gaps/TODOs. Surface
735
+ Identity IDs should be stable human-semantic strings such as `campaign`,
736
+ `landing`, `landing.hero`, `checkout`, `checkout.payment`, or
737
+ `upsell.offer-card`, with labels and aliases for source-specific, DOM, or Page
738
+ Kit names. v0 requires `campaign` plus page-level Surface Identities for active
739
+ or mapped CampaignSpec pages; section and runtime-surface IDs are optional until
740
+ Build or Polish needs them. Do not derive the primary Surface Identity solely
741
+ from CPK `page_type`, Map Builder custom labels, public routes, or producer page
742
+ types. Preserve those as mapped attributes or aliases alongside the Surface
743
+ Identity. For page-level IDs, prefer the CampaignSpec page ID when it is stable
744
+ and human-readable; otherwise derive from normalized page role plus order
745
+ (`landing`, `checkout`, `upsell-1`, `downsell-1`, `receipt`). Always preserve the
746
+ CampaignSpec page ID, Map Builder label/custom name, public route, source aliases,
747
+ and CPK `page_type` separately. `surface_identity[]` is a structured catalog,
748
+ not a simple list of strings. Minimum fields are `id`, `kind`, `label`,
749
+ `aliases`, and `mappings`; page-surface `mappings` preserve CampaignSpec page ID,
750
+ Map Builder label/custom name, public route, producer page type, and Page Kit
751
+ projection. Contribution mappings should reference `surface_identity[].id`
752
+ values and carry relationship metadata such as `coverage_role`, `confidence`,
753
+ `source_refs`, and `notes`. They should not define competing CampaignSpec route
754
+ or Page Kit projection maps. `coverage_role` is a small enum:
755
+ `primary_design`, `partial_design`, `brand_tokens`, `asset_source`,
756
+ `copy_source`, `template_baseline`, `reference_only`, or `fallback_legacy`;
757
+ use `notes` for unusual cases. Mapping `confidence` is also a coarse enum:
758
+ `high`, `medium`, `low`, or `unknown`. It describes confidence in the
759
+ surface/coverage mapping, not design quality or approval. Low confidence blocks
760
+ source readiness only when it affects required page-level `primary_design`
761
+ coverage; represent that as a Source TODO unless waived. Low confidence on
762
+ brand-token, reference-only, or other non-primary coverage does not by itself
763
+ block the v0 readiness evaluator; any gap or note is a separate explicit record.
764
+
765
+ Screenshot references in the Design Source Package are source-side or
766
+ reference-side proof only: canonical URLs, exports, captured source renders, or
767
+ explicit records that a render is unavailable. Built-output screenshots for the
768
+ current implementation belong in Polish Evidence or later QA evidence, tied to
769
+ the current build fingerprint. Polish should compare against Design Source
770
+ Package refs and Template Reference refs without mutating either source artifact.
771
+ If Polish can capture a missing canonical source render, it should emit a
772
+ proposed source-reference update or Source TODO rather than silently updating the
773
+ Design Source Package. Source preparation owns any package mutation and must
774
+ record it explicitly with attribution.
775
+ Material source-reference refreshes create a new Design Source Package
776
+ fingerprint. Any Build, Polish, or QA evidence tied to the previous source
777
+ fingerprint is stale until refreshed or explicitly waived. v0 determines
778
+ materiality through its explicit projection, not a marker in the package.
779
+ Top-level `generated_at`, generated readiness/readback, notes, visual
780
+ `captured_at` alone, formatting, key order, and normalized record/set order are
781
+ non-material; the exact artifact-byte hash still changes when their serialized
782
+ bytes change.
783
+ Polish Evidence must record both the current build fingerprint and the current
784
+ Design Source Package material fingerprint, conventionally as
785
+ `source_build_fingerprint` for the assembly/build artifact and
786
+ `source_package_material_fingerprint` for the design source context. Freshness
787
+ gates consider Polish current only when both match the latest artifacts;
788
+ if either changes materially, Polish is stale unless a structured waiver explains
789
+ the exception. During the v0 transition, the polish gate enforces
790
+ `source_package_material_fingerprint` only when the Assembly Report exposes a
791
+ current Design Source Package material fingerprint, such as
792
+ `design_source_package.material_fingerprint`. Legacy reports without a current
793
+ source package keep the build-fingerprint gate and emit a readiness warning
794
+ instead of blocking.
795
+
796
+ Build must also record the Design Source Package material fingerprint it
797
+ consumed on `stages.assembly.source_package_material_fingerprint`; Prepare does
798
+ not populate that consumption field. If the current
799
+ `design_source_package.material_fingerprint` is missing from Assembly or differs
800
+ from the Assembly-recorded value, `campaigns-os next` routes back to Build before
801
+ Polish. Polish must review a build made from the current material source context;
802
+ it should not repair or certify a build made from stale design inputs.
803
+
804
+ `stages.assembly.build_fingerprint` is the fingerprint of the built OUTPUT, not
805
+ of its inputs: it changes exactly when the bytes under `_site/<public_route_slug>/`
806
+ change, so a toolkit or template upgrade that renders different output from
807
+ identical source reads as a different build, and identical output on any machine
808
+ at any path yields the same value. The algorithm (`sha256-manifest/v1`): list every
809
+ file under the built route root, path relative to that root with `/` separators,
810
+ sorted by code point; for each file emit one `<path>\n<sha256-hex>\n` pair; the
811
+ fingerprint is `sha256:` plus the SHA-256 of that manifest. Nothing is excluded by
812
+ default (Page Kit writes only rendered HTML and copied assets into `_site/`, nothing
813
+ it timestamps); `.campaign-runtime/page-kit-build-summary.json` lives outside the
814
+ root and is not hashed. Build does not type the value: after page-kit build it runs
815
+ `campaigns-os doctor --packet <packet> --json` and copies
816
+ `derived.build_output_fingerprint.value` (with `.file_count` and `.status`) onto
817
+ `stages.assembly.build_fingerprint`. Doctor recomputes the value on every run
818
+ (`built_output.fingerprint`): a match is a ready line, a missing record is the
819
+ warning `built_output.fingerprint_missing` carrying the value to record, and a
820
+ recorded value the output no longer matches is `built_output.fingerprint_stale`
821
+ (blocking once assembly is complete). The polish gate, QA, and `polish capture`
822
+ compare evidence against that recomputed value, so evidence bound to a build whose
823
+ output has since changed is `polish.output_drift` even when the recorded string
824
+ still matches (`polish.stale` stays the code for evidence stamped against an older
825
+ recorded build). `polish capture` refuses by name when the built route root is
826
+ missing, unreadable, or drifted; symbolic links are never build output and are
827
+ skipped by the walk.
828
+
829
+ A stale or missing Assembly Source Package Fingerprint is waivable only as an
830
+ exceptional Source Freshness Waiver. The waiver must be structured in
831
+ `waivers[]`, with `scope: "assembly_source_package_freshness"` or an
832
+ `applies_to` reference such as
833
+ `stages.assembly.source_package_material_fingerprint`, plus reason, owner or
834
+ waived_by, timestamp, and expiry/review condition. The waiver allows the
835
+ orchestration loop to proceed to Polish, but Polish still must record current
836
+ Polish Evidence, including `source_package_material_fingerprint` when a current
837
+ Design Source Package exists. The waiver must remain visible in Campaign
838
+ Readiness Readback and downstream QA evidence; it is not a silent pass.
839
+ In v0, write accepted Source Freshness Waivers directly into `waivers[]`.
840
+ `campaigns-os checkpoint waive` is a staged generic registry and currently
841
+ accepts four gates: `page_kit.store_profile`, `page_kit.sdk_version`,
842
+ `polish.hidden_eager_media`, and `built_output.upsell_selector_scope`; an
843
+ unregistered gate id is refused with that list. `theme waive` applies the same
844
+ attribution rule (a named human, no placeholder, an optional future
845
+ `--expires-at`) on its own lane. Within Polish, only the broader Source Freshness
846
+ waiver retains its existing report path; theme and QA decisions retain their
847
+ existing artifact or waiver paths until each is explicitly registered.
848
+
849
+ In v0, material source fingerprint fields include contribution identity/kind,
850
+ provenance, presentation intent, Surface Identity catalog and mappings,
851
+ contribution coverage roles, mapping confidence, source refs, source
852
+ screenshot/reference refs, Template Reference linkage, Source Gaps, Source
853
+ TODOs, accepted waivers, and any source divergence or proposed exception that
854
+ has `readiness_affecting: true`. Generated readback prose, formatting/key order,
855
+ and administrative notes are non-material when they do not alter readiness,
856
+ coverage, provenance, or comparison basis. Capture timestamps alone may be
857
+ non-material, but changing the viewport key, URL, dimensions, artifact path, or
858
+ visual artifact hash is material.
859
+
860
+ For renderable contributions that provide page-level `primary_design` coverage,
861
+ source readiness requires at least desktop and mobile screenshot refs. Tablet is
862
+ optional in v0. If a source is renderable but cannot be captured, record a
863
+ Source TODO unless the absence is explicitly accepted as a Source Gap or covered
864
+ by an approved Checkpoint Waiver. Template-baseline pages use the selected
865
+ Template Reference standard viewport refs rather than source-specific captures.
866
+ Use shared viewport keys across source refs, Polish Evidence, and QA evidence:
867
+ `mobile`, `desktop`, and optional `tablet` in v0. Exact width, height, device
868
+ profile, scale factor, browser, capture time, and URL are capture metadata, not
869
+ new viewport names. Avoid stage-specific aliases such as `iphone`, `small`,
870
+ `wide`, or `1440`; keep those details in metadata so cross-stage comparisons can
871
+ join on the same keys.
872
+
873
+ Required page-level coverage applies to every active or mapped page in the
874
+ current build scope. A page is covered by a non-low-confidence `primary_design`
875
+ contribution, an explicit `template_baseline` contribution for template-stock
876
+ pages, or an attributed Source Gap / approved Checkpoint Waiver explaining why
877
+ no primary design source exists. `template_baseline` must reference the selected
878
+ template family/version and Template Reference artifact or contract. If the
879
+ Template Reference proof is missing, record a Source TODO, Source Gap, or waiver
880
+ according to whether the missing proof represents unfinished preparation, an
881
+ accepted source absence, or an approved run exception. Missing page-level
882
+ coverage blocks source readiness.
883
+
884
+ ## Adapter And Proof Fields
885
+
886
+ Fresh packets now include `source_html.adapter_contract`. Build Context and
887
+ Assembly Report carry the same values as `adapter_decisions`, and build agents
888
+ should update the report as they complete work.
889
+
890
+ Required adapter decisions:
891
+
892
+ | Field | Purpose |
893
+ | --- | --- |
894
+ | `raw_html_conversion_status` | Whether prepared HTML has been converted into page-kit-ready source. |
895
+ | `source_asset_strategy` | How images/fonts/CSS/JS are moved and referenced. Prefer `pagekit_campaign_asset_root`. |
896
+ | `commerce_shell_adoption` | Whether checkout/upsell/downsell/receipt use a template-clone-first SDK surface. |
897
+ | `route_rewrite_policy` | How page links, CTAs, and SDK routing values were rewritten from CampaignSpec routes. |
898
+ | `template_files_copied` | Whether the selected template family was copied/verified as one atomic page-kit slice. |
899
+ | `config_script_strategy` | How campaign config scripts are loaded. |
900
+ | `wrapper_policy` | Whether document wrappers are stripped, preserved, or not required. |
901
+ | `frontmatter_policy` | How Page Kit YAML frontmatter is created or preserved. |
902
+ | `script_style_reference_policy` | How scripts/styles move into frontmatter, campaign assets, inline blocks, or passthrough. |
903
+ | `cta_rewrite_policy` | How CTA destinations are rewritten from CampaignSpec routes. |
904
+ | `layout_choice` | Which Page Kit layout strategy wraps the prepared source. |
905
+
906
+ Fresh build context also includes `source.asset_crawl`
907
+ (`source-asset-crawl/v0`). `prepare-build` scans the source HTML files and
908
+ referenced local CSS, then records each local image/font/CSS/JS asset ref with:
909
+
910
+ - `raw` and `normalized` source refs;
911
+ - `source_path` / `source_exists` resolution under the source root;
912
+ - `pagekit_asset_path` for the campaign asset-root ref to use during assembly;
913
+ - summarized warnings for raw `/assets/...` refs, missing local files, and
914
+ refs that escape the source root.
915
+
916
+ Use this inventory before moving assets into Page Kit. It is deliberately a
917
+ context/report aid, not part of `source_html.pages[]` page binding.
918
+
919
+ `template_files_copied` is intentionally group-based rather than prose-only:
920
+ `pages`, `_includes`, `_layouts`, `assets/css`, `assets/js`, and
921
+ `frontmatter_vocabulary`. Doctor warns when an assembly-complete report still
922
+ shows `pending`/`partial` template copying or misses one of those groups. When
923
+ the status is `complete` or `verified_existing_slice`, `paths` must name
924
+ target-repo-relative proof paths and doctor verifies those paths exist.
925
+
926
+ Fresh packets also include `qa.proof_policy`, mirrored into
927
+ `report.proof_policy`. It records browser QA requirement, typed-card depth,
928
+ localhost Development-domain behavior, non-localhost SDK allowlist requirement,
929
+ order path depth, and operator approval state. Test cards still need no
930
+ permission gate; the explicit field prevents agents from re-litigating proof
931
+ depth in chat. Doctor checks the full field set in both packet and report
932
+ artifacts when present. `order_path_depth` is seeded `common` and set with
933
+ `--order-path-depth <off|common|full>` on `prepare-build`/`start` or later
934
+ with `qa policy set --order-path-depth <depth>`, which also refreshes the
935
+ report mirror; a packet whose depth disagrees with its report mirror draws the
936
+ advisory `qa.proof_policy.order_path_depth_drift` warning naming that command
937
+ (see `docs/qa-and-test-orders.md`, "Purchase-proof coverage"). The `qa` block carries no permission booleans:
938
+ `qa.test_orders_allowed` and `qa.sandbox_test_card_confirmed`, which no command
939
+ read, were removed in supported surface 1.28.0, and doctor warns
940
+ (`qa.removed_policy_fields`) on a packet that still carries either.
941
+
942
+ ### Deploy target
943
+
944
+ `deploy.target` names where the built `_site/` output is served for QA. The
945
+ schema enum is `netlify`, `cloudflare-pages`, `vercel`, `shopify-proxy`,
946
+ `agency-ci`, `local-serve` and `unknown`; doctor blocks (`deploy.target`) on
947
+ any other value. `prepare-build`/`start` take `--deploy-target <target>` and
948
+ default to `unknown`; `qa policy set --deploy-target <target>` changes it later.
949
+
950
+ `local-serve` (added in 1.28.0) is the localhost QA path: nothing is deployed,
951
+ the built output is served on localhost by any static server, and
952
+ `deploy.preview_url` records that origin. Localhost on any port is a Campaigns
953
+ App Development domain (SDK allowed, analytics suppressed), so under
954
+ `local-serve` doctor does not raise `campaign.allowed_domains_confirmed`, reads
955
+ a recorded localhost URL as the intended state (a `ready` line), accepts a
956
+ loopback host (`127.0.0.1`, `[::1]`) with a ready line naming the
957
+ `http://localhost:<port>/` fallback, and warns (`deploy.local_serve_url`) when
958
+ the recorded URL is neither.
959
+ `next` at the deploy stage then hands off a serve-locally prompt and action
960
+ instead of a ship-to-host one. The directory to serve is `_site/`; for a
961
+ root-served campaign (`campaign.route_root: "/"`) the handoff adds that pages
962
+ are served at site-root paths while assets keep the `/<public_route_slug>/`
963
+ prefix, so `_site/` needs the same rewrite of root-level page routes onto
964
+ `/<public_route_slug>/<route>` the production host applies — no single
965
+ directory serves both. The QA stage is unchanged and runs against the recorded
966
+ URL.
967
+
968
+ `local-serve` also selects **local proof mode** for the build stage: page-kit
969
+ is built in the development environment (`CPK_ENV=development npx
970
+ campaign-build --json > .campaign-runtime/page-kit-build-summary.json`) into
971
+ `_site/`, and the build records `stages.assembly.evidence.build_environment:
972
+ "development"` on the Assembly Report (a free-form stage field; no schema
973
+ change). The starter templates gate every vendor loader on the environment,
974
+ and a production build's protocol-relative loaders (`//host/...`) fail over a
975
+ plain-HTTP local serve, voiding polish capture unwaivably; the SDK's `dl_*`
976
+ events still fire in development. Before commit, `campaigns-os page-kit parity
977
+ --packet <packet>` renders the current source in both environments to temp
978
+ directories and proves the served output is the current development render
979
+ and that production differs from it only in environment-gated output, with
980
+ the same page set, route slugs, Campaign Cart pin and `next-api-key`; the
981
+ result is recorded on `stages.assembly.evidence.local_proof.production_parity`
982
+ and doctor reports it as `local_proof.production_parity` (with
983
+ `local_proof.build_environment` for the build record). The PR preview is the
984
+ second check. The toolkit never proposes editing a generated include to make a
985
+ local capture pass. Details and the step order:
986
+ [qa-and-test-orders.md](./qa-and-test-orders.md#local-proof-mode-deploytarget-local-serve).
987
+
988
+ Campaign Build Brief `qa_policy` is deliberately scoped as
989
+ `documented_expectation` metadata. Use it to preserve business QA intent, but
990
+ do not treat it as the enforced gate; doctor/QA enforcement reads the packet
991
+ and report proof policy fields above.
992
+
993
+ ## CampaignSpec Retrieval (`--map-id`)
994
+
995
+ `campaigns-os start` / `campaigns-os prepare-build` accept the CampaignSpec via either of two routes:
996
+
997
+ | Flag | Source | When to use |
998
+ | --- | --- | --- |
999
+ | `--spec <path>` | Local JSON file | Offline work, CI runs against a fixture, or hand-edited spec drafts |
1000
+ | `--map-id <id>` | Map Builder proxy (KV-backed) | Default agentic flow — KV is the source of truth, no file shuttling |
1001
+
1002
+ When `--map-id <id>` is set, the CLI fetches `GET <proxy>/api/spec/<id>` (default `<proxy>` is `https://campaign-map.nextcommerce.com`) and caches the response to `<target>/.campaign-runtime/fetched-specs/<id>.json`. The cached file is what downstream stages read, so the packet's `spec.local_path` always resolves to an on-disk artifact regardless of intake mode.
1003
+
1004
+ Retrieval behavior:
1005
+
1006
+ - **Re-fetch by default.** Every `start` / `prepare-build` invocation re-fetches from KV. KV is the source of truth; the cache file is a debug/inspection artifact, not a performance optimization.
1007
+ - **`--cached-spec`** reuses the cache without a network call. Use for offline iteration or when the proxy is temporarily unreachable.
1008
+ - **`--proxy-base <url>`** overrides the default origin. Use for staging environments or local Worker dev (`wrangler dev`). Spec retrieval carries no credential, so any reachable origin works here — but the same flag also aims the credential-bearing rails (Run Telemetry remit, QA verdict publish, `telemetry list`), and those require `https:` unless the host is loopback (`localhost`, `127.0.0.1`, `[::1]`), which is allowed over plain http with a stderr warning. A plain-http remote proxy is refused before the request. See docs/workflow-findings-sidecar.md (Remit Channel).
1009
+ - Failure modes (HTTP error, `{ok: false}` response, network timeout) surface as clean CLI errors before any packet is written.
1010
+
1011
+ The fetched spec is treated identically to a `--spec`-supplied local file from this point forward — same identity validation, same `prepareBuild` pipeline, same idempotency semantics. Re-running `start --map-id` on the same campaign re-fetches the spec, regenerates the packet, and re-runs doctor. If `design_source` was newly populated since the last run, the doctor's design_source-aware blocker logic surfaces it; if nothing changed, the run is a no-op as far as downstream stages are concerned.
1012
+
1013
+ ## Source HTML Manifest Auto-Population
1014
+
1015
+ When the source HTML root carries a source-html manifest at `<source>/.campaigns-os/source-html-manifest.json` (schema `source-html-manifest/v0`, published at `schemas/source-html-manifest.v0.schema.json`) — or `--design-manifest <path>` names a manifest of that schema anywhere else, for a source root nobody can write to — `campaigns-os prepare-build` reads it and uses its `pages[]` block to populate `packet.source_html.pages[]` directly — bypassing the legacy filesystem-name slug matching. Wherever the manifest lives, its `pages[].path` entries stay relative to `--source`. A `pages[]` entry with `skip_reason` and no `path` declares a template-stock page: its assembly decision carries `template_stock: true` and the locked family, and intake demands no design source for it ([Template-stock pages](design-source-package.md#template-stock-pages-the-family-decides)).
1016
+
1017
+ The source-html manifest remains a producer/source-HTML adapter input. It is not
1018
+ renamed into the Design Source Package. In the normalized source workflow,
1019
+ `prepare-build` uses source-html manifests, filesystem fallback, template-stock
1020
+ inputs, and other adapters to emit a separate public Design Source Package with
1021
+ contributions, coverage, gaps/TODOs, Surface Identity, references, and readback.
1022
+ When source-html data is the available input and the default package path is
1023
+ missing, current v0 `prepare-build` synthesizes the package. If a package already
1024
+ exists, it is validated against the current material inputs and reused byte for
1025
+ byte or refused; it is never silently regenerated. Downstream Build and Polish
1026
+ consume the package concept rather than branching back to
1027
+ `packet.source_html` as a second source model. The emitted package lives at
1028
+ `.campaign-runtime/input/design-source-package.json` by default and is referenced
1029
+ from packet/context/report by path, full artifact hash, and material fingerprint.
1030
+
1031
+ Behavior:
1032
+
1033
+ - The manifest is consumed only when it passes the `source-html-manifest/v0`
1034
+ validator. Unknown schema versions, missing `page_id`, entries with neither
1035
+ (or both) `path` and `skip_reason`, or malformed `source_hash` values log a
1036
+ warning and fall back to filesystem matching so out-of-band tools cannot
1037
+ silently corrupt the packet. Doctor also validates a present manifest at the
1038
+ source root.
1039
+ - A partial-source build is declarable (#238). A page entry may carry
1040
+ `skip_reason` instead of `path` to declare that active page out of source
1041
+ scope (a template-derived page has no source HTML by design), and
1042
+ CampaignSpec `build_scope.mode: "partial"` declares the same thing as a
1043
+ blanket for active pages with no manifest entry and no `design_source`.
1044
+ Declared pages are recorded on the packet as `skip_reason` mappings and on
1045
+ the assembly report under `stages.prepare_build.declared_out_of_scope`;
1046
+ prepare-build reaches `completed_partial` instead of blocking on
1047
+ `MISSING_SOURCE_PAGE`, and the declaration regenerates identically on every
1048
+ `start`/`prepare-build` run because it derives from the spec and manifest.
1049
+ A page that declares `design_source` still blocks without a per-page skip
1050
+ entry, and full/undeclared scope keeps the blocking behavior exactly.
1051
+ - The manifest's `page_id` must match an active CampaignSpec page id. Manifest entries with no matching spec page surface as a `MANIFEST_EXTRA_PAGE` prompt (analogous to the existing `MISSING_SOURCE_PAGE` prompt) so the operator reconciles either the spec or the manifest before build.
1052
+ - Optional manifest `page_url` values must be unique after Page Kit route normalization. Duplicate values surface as `MANIFEST_DUPLICATE_PAGE_URL`; prepare-build keeps the first value for route fallback matching and asks the operator to deduplicate before build.
1053
+ - Path values are relative to the source HTML root (`<source>`), not to the `.campaigns-os/` directory that contains the manifest. For example, use `checkout/index.html`, not `../checkout/index.html`.
1054
+ - An optional top-level `wrapper_policy` key declares the document-wrapper policy for the handed-over source, in the same vocabulary the packet records at `source_html.adapter_contract.wrapper_policy`. prepare-build seeds the adapter contract from it; the `--wrapper-policy` flag overrides it, and with neither the default stays `strip_document_wrappers`. Unlike the keys above, a value outside the vocabulary does not invalidate the manifest — the key is ignored with a warning and the rest of the manifest is used as written. See [docs/source-adapters.md](source-adapters.md#selecting-the-wrapper-policy-at-intake).
1055
+ - The build context records `source.manifest` with `schema_version`, `generator`, `generated_at`, and `page_count`, and the assembly decision log records evidence citing the manifest file.
1056
+
1057
+ When the manifest is absent, prepare-build falls back to filesystem-name slug
1058
+ matching. If exactly one candidate matches an active page, the mapping is
1059
+ recorded as before. If multiple HTML files can satisfy the same page, or if all
1060
+ matching files were already assigned to sibling pages, prepare-build blocks with
1061
+ `AMBIGUOUS_SOURCE_PAGE`, records `context.source.ambiguous_candidates`, and
1062
+ drafts `context.source.manifest_draft` so the operator can write
1063
+ `.campaigns-os/source-html-manifest.json` and choose the intended paths before
1064
+ build.
1065
+
1066
+ ### Page Kit Target Projection
1067
+
1068
+ `source_html.pages[].path` is source provenance. It names the producer/source-root-relative HTML file that should be consumed; it is not necessarily the file path to write under the Page Kit campaign directory.
1069
+
1070
+ Fresh `prepare-build` output also writes `source_html.pages[].page_kit` for mapped pages. This block is the Page Kit target projection:
1071
+
1072
+ - `target_path` is the page file relative to `assembly.output_dir` (`checkout.html`, `receipt.html`, etc.).
1073
+ - `output_path` is the same target file relative to `assembly.target_repo`.
1074
+ - `public_route` is the rendered campaign-rooted route Page Kit should produce.
1075
+ - `page_type` is the CPK runtime/analytics vocabulary (`product`, `checkout`, `upsell`, `receipt`), not the richer CampaignSpec or producer page type. CampaignSpec `select` pages project as CPK `checkout` because they are pre-checkout runtime selection surfaces.
1076
+ - `frontmatter` names the Page Kit frontmatter fields the build should write or preserve.
1077
+ - `permalink_required` is true when Page Kit's filename-derived route would not match `public_route`.
1078
+
1079
+ The Design Source Package should reference this projection without confusing it
1080
+ with Surface Identity. Surface Identity is the campaign-facing join key; Page Kit
1081
+ `page_type`, public routes, output paths, CampaignSpec/Map Builder page IDs,
1082
+ custom labels, and producer page types stay as mapped attributes or aliases.
1083
+
1084
+ `page_map[].output_path` in the Build Context is the same Page Kit target path,
1085
+ not `source_html.pages[].path` appended under `assembly.output_dir`. Build agents
1086
+ should read `source_path` for producer provenance and `page_kit.output_path` for
1087
+ the file to write.
1088
+
1089
+ CampaignSpec `page_url` and legacy `url` values are interpreted as Page Kit
1090
+ routes during projection. That normalization strips `.html`/`index.html`,
1091
+ removes query/fragment values, converts absolute preview URLs to their path, and
1092
+ normalizes trailing slashes before deriving target files and frontmatter routes.
1093
+
1094
+ This prevents mixed-source manifests such as `checkout/index.html` from leaking producer folder structure into `src/<slug>/checkout/index.html`. Campaigns OS owns the Adapter from source/manifest/CampaignSpec into Page Kit shape; Page Kit remains the target.
1095
+
1096
+ `target_path` intentionally uses the terminal route segment (`checkout/step-1/`
1097
+ projects to `step-1.html`). If two routes collapse to the same target filename,
1098
+ prepare-build emits `PAGE_KIT_TARGET_CONFLICT`; change one CampaignSpec route
1099
+ before build instead of letting an agent choose a destination.
1100
+
1101
+ ### Per-page `source_hash` (Slice 6 drift detection)
1102
+
1103
+ Each `manifest.pages[]` entry MAY carry a `source_hash` field — the sha256 hex digest of the source HTML file's contents at the moment the producer wrote the manifest. When present, prepare-build threads the hash onto the matching `packet.source_html.pages[]` mapping. Doctor reads the packet mapping at validate time, computes the current on-disk sha256 of the same file, and warns (`source_html.pages.source_hash`) when they diverge.
1104
+
1105
+ Behavior:
1106
+
1107
+ - Optional on the producer side. Producers that don't emit `source_hash` (pre-Slice-6 manifests, template-stock, hand-authored) keep working; doctor's drift check is silent without a hash to compare.
1108
+ - Warning severity only. A drift never blocks a build — the operator decides whether to re-run the producer to refresh the manifest or accept the local edits.
1109
+ - The warning names the file path and includes both hashes (truncated to 12 chars) so the operator can confirm which file diverged without re-running the producer.
1110
+
1111
+ ### Reference AI-generated producer
1112
+
1113
+ `scripts/reference-ai-producer.mjs` ships in this repo as the smallest possible producer reference. It walks a folder of HTML files (auto-discovery) or accepts explicit `--page page_id=path` mappings, computes sha256 per file, and emits the `source-html-manifest/v0` at the canonical location. Auto-discovery maps `landing.html` to `landing` and nested `checkout/index.html` to `checkout`; duplicate inferred page ids fail fast, so use explicit `--page` mappings for ambiguous layouts.
1114
+
1115
+ Usage:
1116
+
1117
+ ```bash
1118
+ node scripts/reference-ai-producer.mjs \
1119
+ --source <source-root> \
1120
+ --campaign-slug <slug> \
1121
+ [--generator <name@version>] \
1122
+ [--page landing=presell-a.html --page checkout=checkout/step.html]
1123
+ ```
1124
+
1125
+ Real AI agents (Claude, Codex, etc.) that produce campaign source HTML should adopt this manifest shape so doctor's design_source-aware error messages and Slice 6 drift detection work uniformly across producers. The script generates only the manifest; it does not write any HTML.
1126
+
1127
+ ## Authoring-Time Hints (Template Family + Upsell Pattern)
1128
+
1129
+ The CampaignSpec carries two optional **hints** the build agent uses
1130
+ as defaults. Both are hints, not contracts: CLI / operator overrides
1131
+ always win.
1132
+
1133
+ **Campaign-level:** `campaign.preferred_template_family` declares
1134
+ which starter family the campaign was authored against (one of
1135
+ `apollo`, `apollo-mv-single-step`, `olympus`, `limos`, `demeter`,
1136
+ `olympus-mv-single-step`, `olympus-mv-two-step`, `shop-single-step`,
1137
+ `shop-three-step`). The
1138
+ consumer (`preferredTemplateFamily()` in `src/cli.mjs`) reads this
1139
+ at three spec locations and uses it as the default template family
1140
+ when no `--template-family` CLI flag is given.
1141
+
1142
+ Resolution order:
1143
+
1144
+ 1. `--template-family <family>` CLI flag (sets `template_lock.locked: true`).
1145
+ 2. `spec.spec_identity.preferred_template_family`.
1146
+ 3. `spec.campaign.preferred_template_family` (the canonical authoring location).
1147
+ 4. `spec.preferred_template_family` (legacy fallback).
1148
+ 5. `"undecided"`.
1149
+
1150
+ When the flag and the hint disagree, the flag wins and `prepare-build` says
1151
+ so rather than resolving in silence: it prints one stderr line naming the
1152
+ winning flag value, the overridden `preferred_template_family` value, and
1153
+ which channel each came from, and records the same thing on the assembly
1154
+ report as a `prepare_build` warning with code
1155
+ `TEMPLATE_FAMILY_HINT_OVERRIDDEN`. An operator reading the report
1156
+ therefore sees that the packet's family was an override rather than agreement
1157
+ with the spec. A flag that merely repeats the hint is agreement, not an
1158
+ override, and stays quiet. To build on the spec hint instead, re-run without
1159
+ `--template-family`; to remove the disagreement, update the spec so the two
1160
+ match.
1161
+
1162
+ When the hint wins, `template_lock.locked` stays `false` — the family is set as the default but not locked, so a downstream stage (or a follow-up operator pass) can override without contradiction. `template_decision_notes` records the hint source. `template.candidates` in the build context lists the hint with `source: "CampaignSpec preferred_template_family"` for provenance.
1163
+
1164
+ **Per-page:** `Page.upsell_template_pattern` declares the UI variant
1165
+ for an upsell page (one of `mv`, `bundle_tier_pills`,
1166
+ `bundle_tier_cards`, `single`). Flows from the spec page onto
1167
+ `packet.source_html.pages[].upsell_template_pattern` so the build
1168
+ stage can pick the right partial without re-parsing the spec.
1169
+
1170
+ The field is per-page; only upsell pages should carry it. Upstream
1171
+ spec validation warns when it's set on non-upsell pages, but the
1172
+ consumer surfaces it verbatim and lets the build stage decide what
1173
+ to do with it.
1174
+
1175
+ ## Commerce Catalog (`assembly.commerce_catalog`)
1176
+
1177
+ `assembly.commerce_catalog` names the commerce-surface catalog the build and
1178
+ doctor read for the locked template family (`required`, `family`, `version`,
1179
+ `path`).
1180
+
1181
+ - `path: null` means the toolkit's own catalog
1182
+ (`contracts/commerce-surface-catalog.json` of the `campaigns-os` that is
1183
+ running). This is what `prepare-build` records by default. The catalog
1184
+ travels with the toolkit, not with the campaign, so the packet does not
1185
+ record where one machine's checkout or package install kept it, and the
1186
+ same packet resolves on any machine and under `npx campaigns-os`.
1187
+ - A string `path` is an operator-supplied `--commerce-catalog <path>`,
1188
+ recorded relative to the packet (keep it inside the campaign repo). Doctor
1189
+ resolves it against the packet's directory and blocks on
1190
+ `assembly.commerce_catalog.path` when it does not exist.
1191
+ - Packets prepared before `null` was recorded carry the toolkit catalog as a
1192
+ packet-relative path that climbs into the checkout that ran `prepare-build`
1193
+ (`../../../campaigns-os/contracts/commerce-surface-catalog.json`). When such a
1194
+ path does not exist but its file name is `commerce-surface-catalog.json`,
1195
+ doctor and QA resolve it to the running toolkit's catalog and doctor prints
1196
+ a `ready` line saying the packet still carries a machine-local path. That
1197
+ is never a blocker; re-running `prepare-build` records `null`.
1198
+
1199
+ ## Orchestration Loop (`campaigns-os next`)
1200
+
1201
+ `campaigns-os next` (no stage argument) is the agentic orchestration primitive. It reads the current packet, doctor, and assembly report state from disk and tells you which stage should run next. Each call re-reads state, so the loop is idempotent and recoverable across sessions / machines.
1202
+
1203
+ Treat the loop as a sequence of Readiness Checkpoints, not as a required one-shot
1204
+ campaign build. A one-shot run is the best case where inputs are already complete
1205
+ and every checkpoint can advance in one session; the normal path may take several
1206
+ turns or sessions as source gaps, source TODOs, waivers, polish findings, deploy
1207
+ state, and QA blockers are discovered and resolved.
1208
+
1209
+ For source preparation, the Design Source Package should carry a generated
1210
+ readback summary that names included sources, coverage, gaps/TODOs, mappings,
1211
+ reference availability, and readiness. The readback helps humans and agents pick
1212
+ up the work later; structured package fields remain authoritative. Later stages
1213
+ should write their own stage readbacks or evidence summaries rather than
1214
+ rewriting the Design Source Readback. Those stage readbacks should be surfaced
1215
+ through a consolidated or just-in-time Campaign Readiness Readback so the
1216
+ operator is not expected to discover a patchwork of separate artifacts. Generate
1217
+ that readiness readback from the latest artifacts as the primary behavior; Run
1218
+ Records may snapshot it for audit. `campaigns-os next` should show the concise
1219
+ current-stage readback, while run or campaign status should show the fuller
1220
+ campaign-level readback. The readback should include readable prose plus stable
1221
+ buckets: `current_checkpoint`, `readiness_status`, `handled`, `blocked_by`,
1222
+ `known_gaps`, `proposed_exceptions`, `waivers`, `evidence_refs`, and
1223
+ `next_actions`. `evidence_refs` should point to source package sections,
1224
+ screenshots, Polish Evidence, Assembly Report stages, deploy URLs, QA verdicts,
1225
+ or other owning artifacts; the readback summarizes evidence but does not embed
1226
+ the detailed proof. UI surfaces may render screenshot thumbnails from refs, but
1227
+ CLI/readback data should keep screenshots as references with a short statement of
1228
+ what each proves.
1229
+
1230
+ Checkpoint status should stay boring and shared: `pending`, `blocked`, `ready`,
1231
+ `ready_with_gaps`, `ready_with_waivers`, `completed`,
1232
+ `completed_with_warnings`, or `skipped`. Put stage-specific detail in evidence,
1233
+ gaps, TODOs, waivers, findings, and next actions. `ready_with_waivers` requires
1234
+ structured waiver evidence: owner, reason, scope, applies-to references, created
1235
+ time, and either an expiry or review condition. Stages such as polish may draft
1236
+ or recommend waivers with evidence, but an operator/run decision approves them.
1237
+ Polish must classify every unresolved issue as `repair_needed`, `source_gap`,
1238
+ `source_divergence`, `waiver_recommended`, or `out_of_scope` so the next
1239
+ checkpoint knows whether to fix, carry, approve, or route it. Unresolved
1240
+ `repair_needed` issues block deploy and QA unless repaired, reclassified, or
1241
+ covered by an approved waiver. A `source_divergence` raised by polish is
1242
+ proposed until confirmed by an operator/run decision or the relevant Build or
1243
+ Design Source owner. A `source_gap` raised by polish is proposed too, unless it
1244
+ traces to an accepted Source Gap in the Design Source Package.
1245
+
1246
+ The motion:
1247
+
1248
+ ```text
1249
+ agent calls `next` → gets { stage, prompt, picked_reason } → does the work →
1250
+ updates assembly report's stages.<name>.status → calls `next` again →
1251
+ repeat until stage="done"
1252
+ ```
1253
+
1254
+ Stage order: `setup → build → polish → deploy → qa`. The picker walks this list and returns the first stage whose recorded status isn't terminal (`completed`, `completed_with_warnings`, `skipped`). During Polish, install the package-owned browser first, then run `campaigns-os polish capture` against the served current build before recording a terminal `stages.polish.status` or proceeding to deploy/QA; the producer attaches package-owned `visual_review.page_load` evidence and never marks the stage complete itself.
1255
+
1256
+ | Stage | Report key | Owner |
1257
+ |---|---|---|
1258
+ | setup | `stages.setup` | scaffold the page-kit campaign repo |
1259
+ | build | `stages.assembly` | assemble the campaign (next-campaigns-build) |
1260
+ | polish | `stages.polish` | source-design fidelity pass (next-campaigns-polish) |
1261
+ | deploy | `stages.deploy` | ship `_site/` to Netlify / CF Pages / Vercel / etc. (out-of-band), or serve it locally under `deploy.target: local-serve` |
1262
+ | qa | `stages.qa` | spec-aware QA (next-campaigns-qa) |
1263
+
1264
+ The CLI stage name is `build` but the report keys the same stage as `assembly` — the picker handles the translation. Both names refer to the same lifecycle step.
1265
+
1266
+ The Assembly Report's top-level `status`, `next` and `blockers` are derived from its `stages` on every write of the report (prepare-build's first write and every stage record after it), never carried forward from an earlier write. `status` is `blocked` while any recorded stage is blocked, `completed` only once every recorded stage (`prepare_build` and `doctor` included) is terminal, and `prepared` otherwise. `next.stage` is the first non-terminal stage in the order above, in the `next <stage>` vocabulary (`setup`, `build`, `polish`, `deploy`, `qa`, then `done`; a blocked prepare-build or doctor names `prepare-build` / `doctor-blocked`; a doctor that never recorded an outcome does not hold the ladder but is named `doctor` once the ladder is exhausted, so a `prepare-build --no-doctor` report never reads `completed`), `next.owner` is the skill that owns it, and `next.blocked` is present and true when that stage is the one holding the ladder. `blockers` is the union of the `blockers[]` of the stages currently blocked, so a blocker cleared by a re-run leaves the top level with its stage. The report's `next` is the ledger's own position; `campaigns-os next` additionally folds in live gates (doctor findings, purchase-proof coverage, the polish gate) and remains the authority for what runs next.
1267
+
1268
+ Result shape (with `--json`):
1269
+
1270
+ ```jsonc
1271
+ {
1272
+ "ok": true,
1273
+ "status": "ready",
1274
+ "stage": "build",
1275
+ "picked_reason": "Stage \"assembly\" has status \"pending\"; run \"build\" next.",
1276
+ "prompt": "Use next-campaigns-build for this Campaigns OS handoff. ...",
1277
+ "errors": [],
1278
+ "warnings": [],
1279
+ "ready": [],
1280
+ "stage_blocked": false // present only when the recorded status is "blocked"
1281
+ }
1282
+ ```
1283
+
1284
+ Terminal states:
1285
+
1286
+ - **`stage: "doctor-blocked"`** — doctor returned errors. Resolve the blockers and re-run `campaigns-os doctor` to confirm before calling `next` again.
1287
+ - **`stage: "done"`** — every stage is in a terminal status. Pipeline is complete. To re-run a specific stage, set its status back to `"pending"` in the assembly report and call `next` again.
1288
+ - **`stage_blocked: true`** — the picker returned a stage whose recorded status is `blocked`. Don't run the prompt as-is; clear the blocker first.
1289
+
1290
+ The legacy form `campaigns-os next <stage>` (e.g. `next build`) still works and is the way to force a specific stage when you want to override the picker.
1291
+
1292
+ ## Design Source-Aware Coverage Error
1293
+
1294
+ CampaignSpec pages may carry an optional `design_source` block on `Page` — a pointer to the design artifact (Figma file + per-breakpoint selection URLs) that supplies prepared HTML for that page. When doctor detects an active spec page with no source mapping, the `source_html.pages.coverage` error now carries a hint that points the operator at the design source:
1295
+
1296
+ - `design_source.type === "figma"` with `file_url`: doctor calls out the Figma file and the figma-sections-export handoff command (`npm run handoff -- <slug>`).
1297
+ - `design_source` set without `file_url`: doctor flags the missing `file_url` so the spec can be corrected.
1298
+ - `design_source` unset: doctor keeps the original generic coverage error.
1299
+
1300
+ The error code (`source_html.pages.coverage`) is unchanged so existing doctor consumers do not need to be updated; only the human-readable `message` and an optional `detail.design_source` payload are added.