@nextcommerce/campaigns-os 1.60.0 → 1.61.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 (88) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +5 -5
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +768 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +28 -1
  17. package/docs/campaign-build-brief.md +16 -0
  18. package/docs/campaigns-os-build-flow.md +61 -2
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +40 -2
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +4 -4
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +6 -9
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
  46. package/skills.json +11 -11
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +2 -2
  52. package/src/cli.mjs +52 -13
  53. package/src/design-file-reference.mjs +93 -0
  54. package/src/design-source-package.mjs +55 -1
  55. package/src/doctor/checks.mjs +8 -74
  56. package/src/doctor/ci.mjs +112 -0
  57. package/src/doctor/inspect.mjs +18 -0
  58. package/src/doctor/source-provenance.mjs +74 -0
  59. package/src/evidence-debt.mjs +119 -0
  60. package/src/parity/compare.mjs +316 -0
  61. package/src/parity/design-access.mjs +54 -0
  62. package/src/parity/element-parity-record.mjs +569 -0
  63. package/src/parity/probe.mjs +205 -0
  64. package/src/parity/snapshot.mjs +110 -0
  65. package/src/png-header.mjs +87 -0
  66. package/src/polish-browser.mjs +186 -9
  67. package/src/polish-captures-record.mjs +52 -0
  68. package/src/polish-deadline.mjs +14 -0
  69. package/src/polish-gate.mjs +25 -6
  70. package/src/polish-node.mjs +81 -7
  71. package/src/polish-visual-fixtures.mjs +74 -0
  72. package/src/polish-visual.mjs +606 -0
  73. package/src/qa-analytics-correctness.mjs +14 -2
  74. package/src/qa-analytics-parity.mjs +94 -5
  75. package/src/qa-node.mjs +39 -6
  76. package/src/qc-accept.mjs +148 -6
  77. package/src/qc-check-registry.mjs +37 -1
  78. package/src/qc-results.mjs +404 -16
  79. package/src/readback.mjs +57 -13
  80. package/src/sdk-attribute-index.mjs +11 -11
  81. package/src/sdk-markup.mjs +77 -2
  82. package/src/sdk-storage-compatibility.mjs +94 -4
  83. package/src/source-html-manifest.mjs +7 -0
  84. package/src/source-html-server.mjs +129 -0
  85. package/src/stage-record.mjs +45 -7
  86. package/src/theme-gate.mjs +2 -2
  87. package/src/visual-diff.mjs +296 -0
  88. package/src/visual-reference.mjs +126 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_note": "The downstream contract manifest. Everything listed here is SUPPORTED SURFACE: consumers (campaigns-agent, campaign-builder, the private ops repo, page-kit campaign repos) may depend on it, and changing it is a deliberate act — hashed entries require a surface_version bump in the same change (check-supported-surface.mjs --base, mirroring the skills.json bump gate), named entries must keep existing at their path, cli_commands must keep resolving in the CLI dispatch, package_exports must stay exported, and every entry must ship in the npm pack (files[] coverage). Anything NOT listed here — src/** internals, scripts/** checkers, examples/**, prompts/**, contracts/** other than this file and the entries named[] below (the orientation contract, the release ledger, and the consumer-facing orientation fixtures) — is implementation: consumers may read it for context but must not build on it, and it can change without notice. Rationale and the compatibility promise: docs/supported-surface.md.",
3
- "surface_version": "1.60.0",
3
+ "surface_version": "1.61.0",
4
4
  "package_exports": [
5
5
  "./commercial-journey",
6
6
  "./commercial-parity",
@@ -58,7 +58,7 @@
58
58
  "sha256": "d524003184131398dc15fdb84385af1be0de0c69d6c5ede7278689a5b6101b81"
59
59
  },
60
60
  "schemas/campaign-design-source-package.v0.schema.json": {
61
- "sha256": "65bcd8b5f41f1e9ecfc36f3b8838cd4ed531bd74c5a318ee5147131f6c9b4a1c"
61
+ "sha256": "ecb8fd534977826541aa7110dad3483b7b9215dd60ed30b6370083848b957f13"
62
62
  },
63
63
  "schemas/campaign-runtime-assembly-report.v0.schema.json": {
64
64
  "sha256": "625a6f2ed6bee726f9cddad750e64a54a7e03744c777f5a70afe4a39afdd9848"
@@ -112,7 +112,7 @@
112
112
  "sha256": "41ff370de4395149781d7e8c4ad6d7d4e2f896eb2030944399d36f1237726a20"
113
113
  },
114
114
  "schemas/source-html-manifest.v0.schema.json": {
115
- "sha256": "841c1f884260ce3eb3ddb518960a127bc915992a9539c818e5986ce156acff1f"
115
+ "sha256": "58bc98b3fd07c87c6ba92d29b26941ca97200ea433d73f7418966f679841df49"
116
116
  },
117
117
  "schemas/campaigns-os-progress-snapshot.v0.schema.json": {
118
118
  "sha256": "ef31b4f20e39dfc1614dd4b45e4cf1692a51760b008cb51d133659307bd2a954"
@@ -121,7 +121,7 @@
121
121
  "sha256": "2e440be6513fe06b351c3a72c35424a04f807792621cb01e89803441fd925e06"
122
122
  },
123
123
  "schemas/campaigns-os-readback.v2.schema.json": {
124
- "sha256": "dfc9abed38d456969e47a21f606d308a03bdf036f3466f7d6e47a602747dacf4"
124
+ "sha256": "443676656a7d0db4c8c45301dad5c925cd1b03d4d0e3dccdf0132d9a4e6005f2"
125
125
  },
126
126
  "contracts/effects.v1.json": {
127
127
  "sha256": "9c1d69e44eab7403ca3ff80f850214d89b0a02fdc51544beef37dbd0a653eeaa"
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "asset_pin": {
57
57
  "repo": "NextCommerceCo/campaign-cart-starter-templates",
58
- "sha": "3987b18a356cb961b06c94b2035622bafe938316",
58
+ "sha": "b38343e85a05ec2fe949e13e783057d4c717a8d6",
59
59
  "path": "src/<family>/assets/<asset>",
60
60
  "note": "asset_sha256 is the sha256 of each shipped starter asset at this commit (the commerce-surface-catalog _synced_from_sha pin; the bytes are identical across every family). A served asset whose bytes hash to the shipped value is the untouched starter asset and counts as residue even though its markup names no method; different bytes mean it was edited in place. check-template-doctrine verifies these hashes against the pinned checkout."
61
61
  },
@@ -0,0 +1,20 @@
1
+ # Access Model
2
+
3
+ Campaign developers should not need access to the private Campaigns OS runtime development repo.
4
+
5
+ This repo is the developer-facing surface:
6
+
7
+ - docs
8
+ - schemas
9
+ - examples
10
+ - starter-template contract snapshots
11
+ - agent skills
12
+ - Build Packet doctor
13
+ - checkpoint wrappers
14
+ - issue templates
15
+
16
+ Private platform/runtime development remains outside this repo. Do not copy private repo paths, client-specific examples, secrets, dashboard internals, or unpublished platform mutation procedures here.
17
+
18
+ ## Distribution
19
+
20
+ This repo should stay suitable for public or partner access. Private runtime details, private repo paths, secrets, and client-specific implementation notes belong outside this surface.
@@ -37,12 +37,66 @@ the accent fallback and the warning asks the operator to confirm it.
37
37
  ```text
38
38
  .campaign-runtime/theme/theme-report.json
39
39
  .campaign-runtime/theme/brand-theme.css
40
+ .campaign-runtime/theme/brand-fonts.css (only when the source ships brand faces)
40
41
  ```
41
42
 
42
43
  The generated CSS is v0 root-variable-only. It can override next-core custom
43
44
  properties such as `--brand--color--primary` and
44
45
  `--brand--color--cta-primary`, but it must not emit selectors, `data-next`
45
46
  selectors, payment/package/cart selectors, JavaScript, or remote URL fetches.
47
+ The one exception is a `:root, body` block for the font-family tokens (see
48
+ Typography below).
49
+
50
+ ### Typography
51
+
52
+ Commerce pages keep the design's type, not the template's system stack.
53
+ Inspection reads every CSS file the source pages link:
54
+
55
+ - the `@font-face` rules the source ships;
56
+ - which family its headings use (`h1`–`h3`, then `heading`/`title` classes);
57
+ - which family its body copy uses (`html`, `body`, `p`, or the class a
58
+ stylesheet scopes itself under, such as the importer's `.cc-import`);
59
+ - which family its buttons use, and the button `border-radius`.
60
+
61
+ `var()` references resolve through custom properties on `:root`, `html`, `body`
62
+ and that scope class, where Shopify themes keep `--font-body-family` and
63
+ `--buttons-radius`. A family is mapped only when the source ships a face for
64
+ it: a family the page can't load would only change the fallback, so it is
65
+ left out, reported as a `theme.typography.face_unavailable` warning, and
66
+ listed in the theme report's `typography.unavailable`.
67
+
68
+ | Source | next-core token |
69
+ | --- | --- |
70
+ | heading family, then the generic the source gave it | `--_text---font-family--heading` |
71
+ | body family (else the button family), then its generic | `--_text---font-family--primary` |
72
+ | button radius (`0`, or one to four lengths or percentages) | `--radius-size--buttons` |
73
+
74
+ A button radius in another form (`10px / 20px`, an unresolved `var()`) is not
75
+ mapped and is listed in `typography.radius_rejected`.
76
+
77
+ The targets are in the `typography` block of
78
+ `contracts/brand-theme-target-tokens.next-core.v0.json`. next-core re-declares
79
+ both font-family tokens on `body` inside its 991px, 767px and 479px media
80
+ queries, so a `:root` value alone holds only on desktop. The generator emits
81
+ them in a `:root, body` block. `brand-theme.css` loads after next-core, so its
82
+ `body` declaration wins at every width.
83
+
84
+ `brand-fonts.css` holds only `@font-face` rules for the mapped families. It
85
+ always ships `font-display: swap`, whatever the source declared. A face the
86
+ source declares once per format keeps every file, in source order. Its URLs are relative to `assets/css/`, pointing at the
87
+ source's own font files, which build copies into the campaign's `assets/`
88
+ tree with the rest of the source assets. A face whose file is remote, a
89
+ `data:` URL, root-relative, outside the source root or missing is left out with
90
+ `theme.typography.face_src_dropped`; containment is checked on real paths, so
91
+ a symlink out of the source root is dropped. Family names are stripped of
92
+ characters that could not appear in a quoted family. Button radius is mapped only alongside a
93
+ mapped family, so a source with no brand faces generates exactly what it did
94
+ before.
95
+
96
+ Typography covers family and button radius only, because those are the only
97
+ type and shape tokens next-core exposes. Heading weight, letter-spacing and
98
+ text-transform stay at next-core's values: next-core renders headings at 700,
99
+ so a design that sets headings at 600 shows its bold face on commerce pages.
46
100
 
47
101
  ### Foreground tokens are derived from background luminance
48
102
 
@@ -205,8 +259,10 @@ downsell, or receipt pages.
205
259
 
206
260
  If a fresh `brand-theme.css` exists:
207
261
 
208
- 1. Copy it into the campaign asset tree.
209
- 2. Add it to commerce page frontmatter styles after `next-core.css`.
262
+ 1. Copy it into the campaign asset tree (`assets/css/`), with
263
+ `brand-fonts.css` beside it when theme generate wrote one.
264
+ 2. Add it to commerce page frontmatter styles after `next-core.css`. List
265
+ `brand-fonts.css` on the same pages; its position does not matter.
210
266
  3. Preserve SDK-owned runtime surfaces: `data-next-*`, package selectors,
211
267
  payment fields, totals, submit controls, receipt templates, route meta tags,
212
268
  and SDK JavaScript.
@@ -215,7 +271,10 @@ If a fresh `brand-theme.css` exists:
215
271
  page's stylesheet links and records `report.theme.status`, `css_path`,
216
272
  `commerce_pages`, `load_order` and evidence. It refuses, writing nothing,
217
273
  when a page that loads `next-core.css` does not load the brand layer after
218
- it, or when no built commerce page loads `next-core.css`.
274
+ it, when theme generate wrote `brand-fonts.css` (at the Build Context's
275
+ `theme.generated.fonts_css_path`) and a styled page does not load an
276
+ identical copy from inside the built site, or when no built commerce page
277
+ loads `next-core.css`.
219
278
 
220
279
  ### Where next-core.css belongs
221
280
 
@@ -832,7 +832,7 @@ Blockers (not waivable — the markup provably does not do what it says):
832
832
  - `CHECKOUT_NOT_FORM` — `data-next-checkout` on an element that is not `<form>`.
833
833
  - `WRONG_FIELD_NAME` — `data-next-checkout-field` with a value the SDK does not
834
834
  map. The set is vendored from the SDK at a named tag
835
- (`src/sdk-attribute-index.mjs`, currently v0.4.42: `email`, `fname`, `lname`,
835
+ (`src/sdk-attribute-index.mjs`, currently v0.4.43: `email`, `fname`, `lname`,
836
836
  `phone`, `address1`, `address2`, `city`, `province`, `postal`, `country`,
837
837
  `payment-method`, `accepts_marketing`, `cc-number`, `cc-month`, `cc-year`,
838
838
  `exp-month`, `exp-year`, `cvv`, the legacy `card-*` spellings, and any
@@ -867,6 +867,23 @@ Warnings (advisory):
867
867
  `data-next-package-toggle`), or one a `*-template-id` attribute points at.
868
868
  SDK tokens are single-brace; a template nothing in the SDK reads, including
869
869
  a vendor template nested deeper inside SDK chrome, may use any syntax.
870
+ - `PAGE_TYPE_ATTRIBUTE` — `data-next-page-type` on a page with no
871
+ `<meta name="next-page-type">`. The SDK never reads the attribute, so the
872
+ page runs as the default `product` step; on an upsell page no upsell page
873
+ view is recorded. `window.nextConfig.pageType` does not silence it: it
874
+ reaches the SDK's config store but not the upsell page view. The message
875
+ names the meta value to add: `receipt` on a receipt, `upsell` on a page with
876
+ a `data-next-upsell` offer, otherwise the attribute's SDK page type
877
+ (`product`, `cart`, `checkout`, `upsell`, `receipt`; `downsell` reads as
878
+ `upsell`, `thank-you`, `thankyou` and `confirmation` as `receipt`, and
879
+ `presell`, `landing` and `advertorial` as `product`). Any other value is not
880
+ guessed at: the message lists the five SDK page types.
881
+ - `UPSELL_PAGE_TYPE_MISSING` — a `data-next-upsell` offer on a page whose
882
+ `next-page-type` meta is missing or is not `upsell` (the SDK has no
883
+ `downsell` page type). The SDK records the upsell page view only from
884
+ `<meta name="next-page-type" content="upsell">`, so reports show the
885
+ purchase with no upsell view before it. Not raised on a receipt, or when
886
+ `PAGE_TYPE_ATTRIBUTE` already names the page.
870
887
 
871
888
  Information: `data-next-*` names the vendored attribute index does not list are
872
889
  collected on the gate (`unknown_attributes[]`) and printed as one advisory ready
@@ -1029,6 +1046,16 @@ Commit durable packet/context/report artifacts when they represent a real build
1029
1046
 
1030
1047
  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.
1031
1048
 
1049
+ Add `--ci` for the [standing PR evidence check](campaigns-os-build-flow.md#live-campaign-changes):
1050
+ `campaigns-os doctor --packet <packet> --ci --no-live-refs`. CI mode assesses the
1051
+ same build-bound Polish/QA debt as readback, exits 0 for current evidence, 3 for
1052
+ owed evidence, 4 for unknown debt, and 2 for a missing/unreadable packet. Any owed
1053
+ stage returns 3; otherwise an unknown stage returns 4. Other doctor errors remain reported but do not
1054
+ change the CI exit. It prints Markdown for a PR comment, or adds `ci.status`,
1055
+ `ci.exit_code`, `ci.polish`, `ci.qa` and `ci.changes` under `--json`.
1056
+ `--ci` adds no writes or network reads; pass `--no-live-refs` in CI to disable
1057
+ doctor's existing live campaign read. Without `--ci`, inspection behavior is unchanged.
1058
+
1032
1059
  `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.
1033
1060
 
1034
1061
  `campaigns-os start` / `campaigns-os prepare-build` writes portable packet, context, report, and generated doctor-output paths. Resolve each path from its reader's base:
@@ -4,6 +4,22 @@ The Campaign Build Brief is the merchandising and design-presentation truth for
4
4
 
5
5
  CampaignSpec remains the operational truth: packages, offers, shipping, routing, SDK hints, payment/runtime values, and API identity. The Build Brief answers business-owned presentation questions that agents should not infer silently: page authority, palette and CTA treatment, product media rules, pricing display, promo language, payment/trust surfaces, canonical names, residue policy, and QA expectations.
6
6
 
7
+ ## Design Authority
8
+
9
+ For a page built from an HTML export of a named design file, the drafted brief
10
+ records `design_authority.<page>.source: design_file`, with `reference` set to
11
+ the design file link, `frames` set to the declared frame links, and
12
+ `derived_artifact` naming the export (`provided_design_export` and its HTML
13
+ path). The summary reads "follow the design file, built from its HTML export".
14
+ A Figma spec `design_source` supplies this lineage only with
15
+ figma-sections-export provenance that passes doctor's
16
+ `source_html.producer_provenance` check without a waiver; a manifest
17
+ `design_file` is its own declaration.
18
+ A value you state in the brief file is kept as you wrote it. This records
19
+ authority; it does not read the design file or compare elements. Doctor's
20
+ `design.access` reports that access as unexercised, with guidance to continue or
21
+ compare the export and file by hand.
22
+
7
23
  ## Artifact Locations
8
24
 
9
25
  Campaigns OS accepts YAML or JSON:
@@ -17,6 +17,65 @@ The happy path is intentionally tight:
17
17
 
18
18
  Pause only for missing inputs, doctor blockers, blocked deploys, out-of-scope runtime pages that block checkout proof, or merchant-specific uncertainty. The default path should not branch into external browser skills or hand-built backend order creation.
19
19
 
20
+ ## Live-campaign changes
21
+
22
+ Keep a standing pull-request check after launch: install locked dependencies
23
+ with `npm ci`, build the campaign site with
24
+ `CPK_ENV=development npx --no-install campaign-build` (the check does not read the build summary),
25
+ then run `npx --no-install campaigns-os doctor --packet <packet> --ci --no-live-refs`.
26
+ Run these from the Page Kit folder containing the pinned tools and committed
27
+ packet, local CampaignSpec, Build Context and Assembly Report. The site build
28
+ must produce `_site/<public_route_slug>/`; `campaigns-os build` prepares inputs
29
+ and does not build the site. Do not record a new build merely to clear the check.
30
+ Match the Assembly Report's build environment: the documented local-proof flow
31
+ uses `development`; set `CPK_ENV=production` when the recorded evidence reviewed
32
+ a production render instead. Environment differences also change fingerprints.
33
+
34
+ | CI status | Exit code | Meaning |
35
+ |---|---|---|
36
+ | `current` | `0` | Polish and QA both bind to the build on disk and neither is lifecycle-required. |
37
+ | `owed` | `3` | Polish or QA binds to another build or is lifecycle-required; evidence is owed. |
38
+ | `unknown` | `4` | Output or assembly fingerprints are missing, a stage fingerprint is missing without a lifecycle requirement, output is unreadable, or inspection throws; debt cannot be determined. |
39
+ | `usage` | `2` | The packet is missing, unreadable or invalid JSON. |
40
+
41
+ Any owed stage returns 3; otherwise an unknown stage returns 4. Normal
42
+ doctor errors remain visible but do not determine the CI exit code. Without
43
+ `--ci`, doctor retains its usual inspection output and 0/2 result. `--json`
44
+ adds a `ci` object containing `status`, `exit_code`, `polish`, `qa`, and
45
+ `changes` when assessment completes. Without `--json`, every CI outcome prints
46
+ an escaped Markdown report, including a "could not evaluate" report for packet
47
+ usage errors (exit 2) or inspection failures (exit 4). Page changes are compared
48
+ with the retained assembly manifest, so the page list may be empty if that
49
+ manifest was already updated; old Polish/QA bindings still show debt.
50
+ Without a usable manifest, page changes are unknown, but matching stage
51
+ bindings remain current unless lifecycle-required, and differing bindings remain owed.
52
+ The check reads recorded fingerprints, not machine-local capture files.
53
+ Readback's `evidence_debt` uses the same assessment, showing an agent this debt
54
+ before it edits; its separate time-based staleness assessment is not the CI signal.
55
+
56
+ Copy the [workflow example](https://github.com/NextCommerceCo/campaigns-os/blob/main/docs/examples/campaign-repo-evidence-check.yml) into the
57
+ campaign repository and adjust the packet path. It posts or updates one PR
58
+ comment using the fixed `<!-- campaign-evidence-check -->` marker. Evidence
59
+ reports use stdout only; stderr stays in the job log. If the command produces
60
+ no stdout, the comment points to the job log with a fixed fallback message.
61
+ If install or build fails before doctor runs, the comment is updated with
62
+ "Campaign build failed; evidence not evaluated. See the job log."
63
+ Evidence findings are **report-only by default**. Opt in to failing on a nonzero doctor
64
+ exit by setting the repository Actions variable
65
+ `CAMPAIGNS_OS_EVIDENCE_CHECK` to `fail`. Install/build failures still fail normally.
66
+ Fork pull requests may lack comment-write permission; comment failures are
67
+ nonfatal and the report remains in the job log. The example uses `pull_request`
68
+ and does not run untrusted code with `pull_request_target` privileges.
69
+
70
+ To repay fingerprint-drift debt, rebuild and record the output, re-run `polish capture` against
71
+ that served build, record Polish with `record polish --evidence <evidence.json>`,
72
+ then run `qa run` to record QA for the current build. For lifecycle-required
73
+ stages with matching build bindings, re-run the required stages against the
74
+ existing build: `polish capture` and `record polish` when Polish is owed, and
75
+ `qa run` when QA is owed. The CI report names each owed stage's cause and
76
+ selects the remedy accordingly. The workflow only inspects;
77
+ it does not renew evidence or deploy.
78
+
20
79
  ## Partial Builds
21
80
 
22
81
  Partial builds are valid campaign work. A pass may build only new presell pages,
@@ -83,11 +142,11 @@ the assembly report.
83
142
  - Landing and presell pages should preserve prepared HTML when it is a real standalone design. Use page-kit passthrough structure, inject the SDK/config requirements, and repoint CTAs into the CampaignSpec flow.
84
143
  - **Pre-checkout pages must ship the same SDK bootstrap as the checkout layout.** Presell and landing pages are SDK `page_type: product`; they need `config.js` (before the loader), the `campaign-cart@v{sdk_version}/dist/loader.js` module script, and the `next-funnel` + `next-page-type` meta tags — not just inert `data-next-*` attributes. Without the loader the SDK silently no-ops: `data-next-hide` conditional visibility (`param.banner` / `param.seen`), `utmTransfer` UTM/query carry-through to checkout (top-of-funnel ad attribution), and SDK analytics never fire. Treat `param.banner` / `param.seen` visibility and `utmTransfer` as standard pre-checkout wiring, not per-campaign discoveries. Doctor enforces this with `built_output.pre_checkout_sdk_bootstrap`.
85
144
  - **Every page names the same campaign.** A page borrowed from another funnel (a copied upsell or receipt) must not keep the other campaign's `next-api-key` / `config.js` API key, `next-funnel` meta, or `setAttribution({ funnel })` call; the SDK reads these per page and reconciles nothing, so the order lands on or attributes to the wrong campaign with no visible error. Doctor blocks this, unwaivably, with `built_output.campaign_identity` (one error per drift, naming both files and both values).
86
- - **SDK markup must do what it says.** `data-next-checkout` goes on the `<form>`; `data-next-checkout-field` values are the SDK's fixed names (`fname`, `lname`, `postal` — never `firstName`, `lastName`, `zip`); an `add-to-cart` button linked by `data-next-selector-id` needs a selector with that id and that selector in `select` mode (swap mode plus the button writes the cart twice); every `data-next-upsell-action` sits inside its `data-next-upsell` container, on any page type; one default-selected card per selector; single-brace tokens inside SDK templates. Doctor enforces the first five as blockers and the last two as warnings under `built_output.sdk_markup` (codes `SWAP_WITH_ADD_TO_CART`, `CHECKOUT_NOT_FORM`, `WRONG_FIELD_NAME`, `MISSING_SELECTOR_ID_MATCH`, `ORPHANED_UPSELL_ACTION`, `DOUBLE_SELECTED`, `TEMPLATE_DOUBLE_BRACE`).
145
+ - **SDK markup must do what it says.** `data-next-checkout` goes on the `<form>`; `data-next-checkout-field` values are the SDK's fixed names (`fname`, `lname`, `postal` — never `firstName`, `lastName`, `zip`); an `add-to-cart` button linked by `data-next-selector-id` needs a selector with that id and that selector in `select` mode (swap mode plus the button writes the cart twice); every `data-next-upsell-action` sits inside its `data-next-upsell` container, on any page type; one default-selected card per selector; single-brace tokens inside SDK templates; the page type in `<meta name="next-page-type">` (`upsell` on every upsell offer page), never only in a `data-next-page-type` attribute. Doctor enforces the first five as blockers and the last three as warnings under `built_output.sdk_markup` (codes `SWAP_WITH_ADD_TO_CART`, `CHECKOUT_NOT_FORM`, `WRONG_FIELD_NAME`, `MISSING_SELECTOR_ID_MATCH`, `ORPHANED_UPSELL_ACTION`, `DOUBLE_SELECTED`, `TEMPLATE_DOUBLE_BRACE`, `PAGE_TYPE_ATTRIBUTE`, `UPSELL_PAGE_TYPE_MISSING`).
87
146
  - **Campaign scripts must parse.** A hand-edited script with a stray or missing bracket throws a `SyntaxError` on every page that loads it, and nothing in it runs. Doctor parses every campaign-owned `.js` file a built page loads by a local `<script src>` and blocks, unwaivably, with `built_output.script_syntax.parse_failure`, naming the file, line and column.
88
147
  - Checkout, upsell, downsell, and receipt pages should preserve starter-template SDK contracts while keeping the campaign/source visual language. Treat starter templates as the reference for required `data-next-*` controls and wiring, not as a mandate to carry their visual chrome into the final campaign.
89
148
  - If source HTML declares SDK-owned zones such as `data-commerce-zone="checkout-form"` or `data-commerce-zone="order-summary"`, keep the selected starter-template family's SDK wiring for that zone (the checkout form, bound fields, payment submit and cart summary). The wrapper and page composition around it stay source-owned; do not add family class names to the campaign's own layout to satisfy QA.
90
- - If `context.theme` names a generated `brand-theme.css`, copy it into campaign assets and load it after `next-core.css` on checkout, upsell, downsell, and receipt pages. Generated brand-theme v0 is root-variable-only; do not use it as permission to edit SDK-owned selectors or runtime structure.
149
+ - If `context.theme` names a generated `brand-theme.css`, copy it into campaign assets and load it after `next-core.css` on checkout, upsell, downsell, and receipt pages. When `context.theme.generated.fonts_css_path` names a `brand-fonts.css`, copy it beside the brand layer and load it on the same pages, so headings and body copy use the design's faces (`record theme` refuses a styled page that skips it). Generated brand-theme v0 is root-variable-only; do not use it as permission to edit SDK-owned selectors or runtime structure.
91
150
  - Buy-more-save-more selectors should use selected quantity plus Offer-aware price displays. Do not swap in stale package-per-tier IDs unless the CampaignSpec explicitly represents an older campaign that still owns separate packages for each option.
92
151
  - SDK routing meta tags should be emitted as campaign-root paths, for example `/campaign-slug/upsell/`, even when the CampaignSpec source value is slug-relative like `upsell/`.
93
152
  - One-time prepurchase/order-bump packages outside the main bundle should default to fixed quantity and fixed line total display unless the spec explicitly requires syncing quantity with the main bundle.
@@ -0,0 +1,116 @@
1
+ # CampaignSpec Authoring Examples
2
+
3
+ Use these examples when authoring or generating common Campaign Map shapes. They
4
+ are public-package guidance: the goal is clearer exported CampaignSpec fields,
5
+ not a private merchant workflow.
6
+
7
+ ## Hero Product + Order Bump + Upsell + Receipt
8
+
9
+ Reference fixture: [single-step checkout, upsell and receipt](https://github.com/NextCommerceCo/campaigns-os/blob/main/contracts/fixtures/campaign-specs/shop-single-step-upsell-receipt.json).
10
+ Fixtures are repository test data and are not included in the installed package.
11
+
12
+ For CampaignSpec 5.0 multi-step shapes, compare the [three-step checkout](https://github.com/NextCommerceCo/campaigns-os/blob/main/contracts/fixtures/campaign-specs/shop-three-step-dynamic-shipping.json)
13
+ and [select-to-checkout](https://github.com/NextCommerceCo/campaigns-os/blob/main/contracts/fixtures/campaign-specs/olympus-mv-two-step-configurable.json) examples.
14
+ Author new specs against the shipped [v5 schema](../schemas/campaign-spec.v5.schema.json).
15
+
16
+ Canonical page shape:
17
+
18
+ ```text
19
+ checkout -> upsell -> receipt
20
+ ```
21
+
22
+ Authoring guidance:
23
+
24
+ - Checkout page:
25
+ - `type: "checkout"` with `sdk_hints.sdk_page_type: "checkout"`
26
+ - `page_url: "/checkout/"` — `page_url` is the route field.
27
+ - `packages[]` entries use `ref_id` + `qty` (the real export key is `qty`,
28
+ not `quantity`). A plain entry is the main package; add-ons carry the
29
+ boolean role flags `is_order_bump: true` / `is_upsell: true` — there is
30
+ no `role` field in exports.
31
+ - `sdk_hints.template_family` names the intended starter family only as a
32
+ hint; the Build Packet still locks the family.
33
+ - `sdk_hints.meta_tags.next-success-url` points to the next runtime route.
34
+ - `success_url` holds the next PAGE ID (never a URL, despite the name).
35
+ - A checkout may equally declare its forward step as `next_page`; twelve
36
+ pages across ten certified fixtures do. Forward links resolve from
37
+ whichever routing field a page declares — `on_accept`, then `success_url`,
38
+ then `next_page` — so an unconventional journey is wired rather than
39
+ dropped.
40
+ - `next_page` is the generic "wherever this page goes next" and is honoured on
41
+ every page type. The other two forward fields carry a page-shaped meaning
42
+ and are honoured only where that meaning exists:
43
+ - `success_url` means "after payment succeeds" — only on `type: "checkout"`.
44
+ - `on_accept` means "after accepting the offer on this page" — only on
45
+ `type: "upsell"` or `"downsell"`.
46
+
47
+ Declared anywhere else they are inert, `next_page` wins, and the
48
+ `RouteFieldIgnoredForPageType` warning says which field was skipped and
49
+ where the shopper actually goes. A `select` page is the first step of a
50
+ two-step checkout and a `checkout_step` is a non-final page of a split
51
+ checkout: neither takes payment nor presents an accept/decline offer, so a
52
+ copy-pasted `success_url` or `on_accept` there does not route.
53
+ - Multi-step checkout (CampaignSpec 5.0): `checkout` is the page that takes
54
+ payment, and it forwards to an upsell, downsell or thank-you page. A page
55
+ with a checkout form that collects details and moves on without placing an
56
+ order is `type: "checkout_step"` and routes with `next_page`; a bundle
57
+ picker before checkout is `type: "select"`. Every path from an entry page
58
+ to an upsell passes exactly one `checkout`; a campaign may hold several, one
59
+ per split-test path. A 4.2/4.3 spec that chained `checkout → checkout` is
60
+ read as `checkout_step` pages ahead of the last one.
61
+ See the [v5 schema](../schemas/campaign-spec.v5.schema.json) for the
62
+ authoring fields; runtime routing is projected by Campaigns OS.
63
+
64
+ - Upsell page:
65
+ - `type: "upsell"` or `type: "downsell"`, with
66
+ `sdk_hints.sdk_page_type: "upsell"`
67
+ - `page_url` is a Page Kit route, not a source filename.
68
+ - `packages[]` (with `is_upsell: true`) or `offers[]` names the
69
+ post-purchase offer refs. Offers are identified by `ref_id` into the
70
+ root `offers[]` catalog (`package_ref_id` is not part of the contract).
71
+ - `on_accept` and `on_decline` hold the next PAGE IDs; route paths live
72
+ in `page_url` / derived `resolved_routing`.
73
+
74
+ - Receipt page:
75
+ - `type: "thankyou"` — the canonical terminal page type. The SDK-layer
76
+ `receipt` projection is expressed as `sdk_hints.sdk_page_type: "receipt"`
77
+ (and `meta_tags["next-page-type"]`), never as `page.type`.
78
+ - `page_url: "/receipt/"`
79
+ - Receipt summary/frontmatter hints preserve SDK-owned order item templates.
80
+
81
+ Generator checklist:
82
+
83
+ - Always emit stable `page.id` values; page IDs are the join key for
84
+ `source_html.pages[]`.
85
+ - Prefer Page Kit public routes (`checkout/`, `upsell/`, `receipt/`) over
86
+ filenames (`checkout.html`).
87
+ - Keep package, shipping, voucher, and offer refs in CampaignSpec/API fields,
88
+ not in source HTML.
89
+ - Put template-family preference in `campaign.preferred_template_family` when
90
+ useful, but require the Build Packet/template lock before commerce wiring.
91
+ - Keep checkout/upsell/downsell/receipt runtime surfaces template-clone-first;
92
+ source HTML can own visual composition and copy, not payment/order mutation.
93
+
94
+ ## Order Bump Notes
95
+
96
+ An order bump is still a checkout package/offer surface. It should be encoded as
97
+ a checkout page package with `is_order_bump: true`, a `qty`, and its source
98
+ `ref_id`.
99
+ When the selected starter family does not support a bump, the build should
100
+ remove the bump surface and record the decision in the Assembly Report instead
101
+ of leaving demo bump refs in copied frontmatter.
102
+
103
+ ## Multi-Upsell Depth
104
+
105
+ For more than one post-purchase page, keep each upsell/downsell page explicit in
106
+ `funnels[].pages[]` and wire `on_accept` / `on_decline` to the next page. QA
107
+ then derives typed-card proof coverage from topology. `common` runs every
108
+ actual terminal path when they fit under the flood cap; above it, it runs
109
+ checkout, first-offer accept and decline, and a deduplicated shortest real
110
+ receipt path, then adds the shortest path that clicks the decline on each offer
111
+ or downsell page not yet declined, up to the cap. Explicit paths support targeted
112
+ repair, while `full` walks every actual terminal path in the selected checkout
113
+ topology. Cycles, missing routes, and reachable nonterminals block exhaustive
114
+ proof before browser launch. The accidental-flood cap remains `6`; when the
115
+ planned count exceeds it, the command names the exact explicit
116
+ `--max-test-orders` raise.
@@ -32,6 +32,48 @@ Unknown top-level fields are rejected.
32
32
 
33
33
  ## Contributions and coverage
34
34
 
35
+ **A design file behind an HTML export.** Name it on the page's source-html
36
+ manifest entry as `design_file: {type, file_url, breakpoints}`. `breakpoints`
37
+ holds optional desktop, tablet and mobile frame links. Intake records a separate,
38
+ non-renderable contribution (`figma_frames` with native trust for Figma, otherwise
39
+ `other` with opaque trust), with `reference_only` coverage. The page's
40
+ `html_funnel` mapping links to it through `derived_from`. Pages sharing a file
41
+ share one contribution. A CampaignSpec page's Figma `design_source` records lineage
42
+ only with figma-sections-export provenance that passes doctor's
43
+ `source_html.producer_provenance` check without a waiver; then it wins when both
44
+ channels are present. Otherwise, a spec pointer alone adds no contribution or
45
+ lineage at intake, and no `design.access` row unless the package already records
46
+ a spec contribution. A Figma `design_source` that names a different file or
47
+ frames from the manifest's `design_file` is reported as a conflict for review
48
+ even when it records no lineage, as is a file key that differs from the
49
+ exporter's. A file shared by several pages keeps the manifest's provenance if
50
+ any page declared it in the manifest.
51
+
52
+ Malformed `design_file` values remain non-fatal manifest warnings; the schema
53
+ accepts them so validation agrees with intake. File URL schemes are
54
+ case-insensitive. Explicit tool types are lowercased for identity. For a type
55
+ other than `figma` or `other`, the design-file source ref's optional
56
+ `design_file_type` string preserves the type doctor needs to reconstruct the
57
+ recorded identity even after a declaration edit. This new field is material;
58
+ source-reference `notes` remain nonmaterial, including on `design_file` refs.
59
+
60
+ Recording a file adds no readiness requirement, Source TODO or blocker. A
61
+ manifest edit, including `design_file`, follows the existing stale-package rule:
62
+ rerun intake with `--force`. A later spec declaration leaves the package current;
63
+ when eligible under the Figma provenance rule above, doctor reports the
64
+ unrecorded file with the same recovery guidance.
65
+ `design.access` reports `unexercised` / `design_access_missing` when the recorded
66
+ file agrees with the declaration. For a recorded spec contribution, doctor
67
+ compares the current spec declaration even if today's manifest or provenance is
68
+ unavailable. This version cannot read design files. This
69
+ records authority only; Polish element parity compares the built page with its
70
+ source HTML, not the design file (see [Polish evidence](polish-evidence.md)).
71
+ A changed design file is listed for review; changing only breakpoint frames on
72
+ that file does not change its identity. If the package records a file but the
73
+ current declaration is absent, `design.access` reports `review` /
74
+ `design_reference_missing`: restore the declaration, or rerun intake with
75
+ `--force` to drop the recorded file.
76
+
35
77
  Each `contributions[]` record identifies one source contribution and records:
36
78
 
37
79
  - `id`, `kind`, `trust`, and whether it is `renderable`;
@@ -275,10 +317,10 @@ The projected fields are explicit:
275
317
  | Contribution core | `id`, `kind`, `trust`, `renderable`. |
276
318
  | Provenance | `source_type`, `adapter`, `producer`, `generator`, `generator_version`, `source_root`, `manifest_schema_version`, `manifest_path`, `manifest_sha256`, `producer_material_fingerprint`, `asset_crawl_schema_version`. |
277
319
  | Presentation intent | `summary`, `composition`, `content_hierarchy`, `imagery`, `copy`, `brand`, `responsive_behavior`. |
278
- | Source reference | `id`, `kind`, `path`, `url`, `sha256`, `role`, `media_type`. |
320
+ | Source reference | `id`, `kind`, `path`, `url`, `sha256`, `role`, `media_type`, optional `design_file_type` (only on `design_file` refs). |
279
321
  | Visual reference | `id`, `kind`, `viewport`, `availability`, `url`, `path`, `sha256`, `width`, `height`, `device_profile`, `scale_factor`, `browser`, `source_ref_id`, `unavailable_reason`. |
280
322
  | Template Reference | `id`, `family`, `version`, `contract_path`, `artifact_path`, `sha256`, `standard_viewport_refs` using the visual projection above. |
281
- | Coverage mapping | `id`, `surface_id`, `coverage_role`, `confidence`, `source_refs`, `screenshot_refs`, `reference_refs`, `template_reference_id`. |
323
+ | Coverage mapping | `id`, `surface_id`, `coverage_role`, `confidence`, `source_refs`, `screenshot_refs`, `reference_refs`, `template_reference_id`, optional `derived_from`. |
282
324
  | Source Gap | `id`, `kind`, `scope`, `applies_to`, `reason`, `status`, `attributed_by`, `attributed_at`, `evidence_refs`. |
283
325
  | Source TODO | `id`, `kind`, `scope`, `applies_to`, `description`, `status`, `owner`, `required_viewports`, `source_ref_ids`. |
284
326
  | Approved waiver | `id`, `scope`, `applies_to`, `reason`, `status`, `waived_by`, `waived_at`, `expires_at`, `review_condition`, `evidence_refs`. |
@@ -458,6 +500,8 @@ only when filename matching was ambiguous, and is `null` otherwise.)
458
500
 
459
501
  ### The record shape
460
502
 
503
+ Polish now reads these files. Record `scale_factor` (the device pixel ratio the screenshot was taken at) and keep `width`/`height` equal to the image's pixel size: a screenshot is compared with the built page only when its width divided by `scale_factor` equals the capture width, and a screenshot without `scale_factor` is not compared. Link it to its source page (`source_ref_id`, filled in for you) and record `captured_at`, or it is listed for review.
504
+
461
505
  One manifest page entry with its proof, inside the envelope the validator requires
462
506
  (the mobile record is elided):
463
507