@nextcommerce/campaigns-os 1.61.0 → 1.62.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 (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
@@ -351,6 +351,53 @@ action. A configured campaign whose pin is newer than the spec's is the
351
351
  advisory case above: no required action, and sync never moves that pin
352
352
  backwards; `spec derive` is the command that closes it from the spec side.
353
353
 
354
+ ### Template analytics vendors (`page_kit.analytics_vendors`)
355
+
356
+ The certified starter templates ship a direct adapter for eight analytics
357
+ vendors: GA4, AppLovin Axon, Taboola, Triple Whale, TikTok, Northbeam,
358
+ Snapchat and Pinterest. A vendor is switched on by its id key in the
359
+ campaign's `_data/campaigns.json` entry (`ga4_id`, `axon_event_key`,
360
+ `taboola_account_id`, `triplewhale_name`, `tiktok_pixel_id`,
361
+ `northbeam_client_id`, `snap_pixel_id`, `pinterest_tag_id`); absent or empty
362
+ means off. The adapter forwards the SDK's `dl_*` events to the vendor. These
363
+ vendors are separate from the SDK providers in `analytics.providers` (GTM,
364
+ Meta, RudderStack, a custom endpoint), which reach the page through
365
+ `config.js`.
366
+
367
+ The CampaignSpec declares them under `analytics.vendors`, keyed by vendor,
368
+ in the shape the Map Builder saves:
369
+
370
+ ```json
371
+ "analytics": {
372
+ "vendors": {
373
+ "triplewhale": { "enabled": true, "id": "acme.myshopify.com", "platform": "SHOPIFY", "identityEnabled": true, "blockedEvents": ["dl_purchase", "dl_upsell_purchase"] },
374
+ "tiktok": { "enabled": true, "id": "CABCDEFGHIJ1234567890" }
375
+ }
376
+ }
377
+ ```
378
+
379
+ `id` is the value of the vendor's id key, `platform` is Triple Whale's
380
+ `triplewhale_platform`, `identityEnabled` turns on the vendor's identity or
381
+ advanced-matching flag (raw email and phone), and `allowedEvents` /
382
+ `blockedEvents` are the `dl_*` events the adapter forwards or drops. The Map's
383
+ spec compiler writes the matching keys into `campaigns.json`, and
384
+ `spec derive` mirrors the id back (see below).
385
+
386
+ Once the target entry exists, doctor compares the two sides and warns, per
387
+ vendor, when:
388
+
389
+ | Code | Meaning |
390
+ |---|---|
391
+ | `page_kit.analytics_vendors.key_missing` | the spec declares the vendor but its id key is absent or empty, so the template never loads it |
392
+ | `page_kit.analytics_vendors.key_placeholder` | the id key holds a placeholder (`XXXX`, `YOUR_`, `CHANGEME`, `<…>`, `EXAMPLE`); the template loads the vendor for any non-empty value |
393
+ | `page_kit.analytics_vendors.undeclared` | the id key switches the vendor on but the spec does not declare it, declares it with `enabled: false`, or turns analytics off |
394
+ | `page_kit.analytics_vendors.id_mismatch` | both sides name the vendor with different ids; the `campaigns.json` value is what ships |
395
+
396
+ Every finding is a warning and names only keys and vendors, never a value
397
+ read from `campaigns.json`. Doctor writes nothing. `spec derive` clears an
398
+ `undeclared` or `id_mismatch` finding from the spec side; the others are
399
+ fixed in `campaigns.json` or in the Map.
400
+
354
401
  ### Changing a campaign after handoff
355
402
 
356
403
  For page-kit campaigns, the reviewed repository spec determines the next build.
@@ -401,8 +448,9 @@ This procedure does not grant new merge, store-write or deployment authority.
401
448
  Do not infer intent from every difference in a stale export. If the repository
402
449
  advances during review, repeat the comparison against the new revision.
403
450
  5. **Refresh and validate the result.** Preview `spec derive --dry-run`, inspect
404
- every refused or unresolved field, then apply the accepted derivation. Plain
405
- derive is local; it does not merge authored intent. Follow the packet's normal
451
+ every refused or unresolved field, then apply the accepted derivation. Derive
452
+ writes repo-derived fields and the offer and package fields mirrored from the
453
+ live campaign; it does not merge authored intent. Follow the packet's normal
406
454
  `page-kit sync`, build, doctor and relevant QA steps. Resolve stale routing
407
455
  hints and rebuild affected pages. A successful derive exit alone is not build
408
456
  or QA approval. After an authored-only edit, derive may return `unchanged`
@@ -442,6 +490,45 @@ routing references and rebuild the affected upsell page. If the developer also
442
490
  changed the selected package to C, record a conflict and obtain a choice before
443
491
  merging. Do not apply B merely because the Map was saved later.
444
492
 
493
+ #### Pages, offers and packages after handoff
494
+
495
+ A campaign is **handed off** once its deploy or its QA has been completed: the
496
+ Assembly Report's `stages.deploy` or `stages.qa` holds a completed record, now
497
+ or in that stage's `history` (a deploy or QA made owed again by a later change
498
+ still happened). From then on the repo's page tree, not the design source, is
499
+ the authority for which pages the funnel has, and the Campaigns App is the
500
+ authority for the offers and packages the spec mirrors. Two commands keep the
501
+ spec in step without running setup or intake again:
502
+
503
+ 1. `campaigns-os spec derive --packet <packet> --dry-run` shows the change, field
504
+ by field; run it again without `--dry-run` to write it. It adds a page the
505
+ tree builds that no spec page binds, removes a page whose built file is gone,
506
+ and refreshes the offer and package fields the spec mirrors from the live
507
+ campaign (see [Deriving the spec from the repo](#deriving-the-spec-from-the-repo-spec-derive)).
508
+ 2. `campaigns-os record spec --packet <packet>` binds the result. A page added
509
+ or removed is bound without intake: the packet's and the Build Context's
510
+ page-kit projection are re-projected, the added page is recorded with
511
+ `origin: "repo"` (its source is its own page file), and the build, Polish
512
+ and QA stages it affects are owed again; deploy follows at the next
513
+ `record build` (see
514
+ [Binding a page change](#binding-a-page-change)).
515
+
516
+ Then rebuild, record the build, and run Polish, deploy and QA as `next` asks.
517
+ Before handoff, `spec derive` lists a page file no spec page binds as
518
+ unclaimed and adds nothing, and `record spec` refuses a page added or removed
519
+ (`page_scope_changed`): the design source decides the pages until the first
520
+ deploy or QA, and a new page there needs intake.
521
+
522
+ To add an upsell after handoff, copy an existing upsell page file to a new
523
+ name (the file name becomes the page id, so `upsell-2.html` is page
524
+ `upsell-2`), keep its `page_type: upsell` frontmatter, and set its `next_url`
525
+ and `decline_url` to the routes it leads to. Point the page before it at the
526
+ new route (for example the first upsell's `next_url`). `spec derive` then
527
+ appends the page to the funnel, sets its `on_accept` and `on_decline` from
528
+ those links, and moves the first upsell's `on_accept` to the new page. The
529
+ repo states links, not positions, so the new page's `order` is reported as not
530
+ derived; set it by hand if the funnel's pages carry one.
531
+
445
532
  #### Map write-back and future portal saves
446
533
 
447
534
  `spec derive --write-map` remains an explicit, pin-only action. A developer or
@@ -469,11 +556,13 @@ this command generates the repo-derived ones so doctor compares generated
469
556
  against generated instead of refereeing a hand-typed value against the repo:
470
557
 
471
558
  ```bash
472
- 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>]
559
+ campaigns-os spec derive --packet campaign-runtime.build.json [--dry-run] [--json] [--report <json>] [--no-live-refs] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>]
473
560
  ```
474
561
 
475
- By default it reads the target repo only (no network unless `--from-store` or
476
- `--write-map`, below) and writes into the packet's local spec (`spec.local_path`):
562
+ It reads the target repo and, once the campaign is built, the live campaign
563
+ in the Campaigns App (below), and writes into the packet's local spec
564
+ (`spec.local_path`). `--from-store` and `--write-map` are the other two
565
+ network paths. The repo-derived fields:
477
566
 
478
567
  | Spec field | Repo authority |
479
568
  |---|---|
@@ -481,15 +570,19 @@ By default it reads the target repo only (no network unless `--from-store` or
481
570
  | `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) |
482
571
  | `analytics.providers.gtm.containerId` | `_data/campaigns.json[public_route_slug].gtm_id` |
483
572
  | `analytics.providers.facebook.pixelId` | `_data/campaigns.json[public_route_slug].fb_pixel_id` |
573
+ | `analytics.vendors.<vendor>.id`, for each starter-template vendor adapter: `ga4`, `axon`, `taboola`, `triplewhale`, `tiktok`, `northbeam`, `snap`, `pinterest` | the vendor's id key in the same entry: `ga4_id`, `axon_event_key`, `taboola_account_id`, `triplewhale_name`, `tiktok_pixel_id`, `northbeam_client_id`, `snap_pixel_id`, `pinterest_tag_id` |
484
574
 
485
- Nothing else is written: not the store profile (store-derived; see
486
- `--from-store` below), not any authored or mirrored field, not the packet,
487
- not the repo. A
575
+ Nothing else is written from the repo: not the store profile (store-derived;
576
+ see `--from-store` below), not any authored or mirrored field, not the packet,
577
+ not the repo, with the one exception of pages after handoff (below). A
488
578
  derived field the spec carries with a different, authored-looking value is
489
579
  overwritten, and the printed `before -> after` line shows it: that is the
490
580
  class doing its job. A block the spec lacks is created (`global_config`,
491
- `analytics.providers.gtm` as `{ "enabled": true, "containerId": … }`); an
492
- array element is never invented.
581
+ `analytics.providers.gtm` as `{ "enabled": true, "containerId": … }`,
582
+ `analytics.vendors.ga4` as `{ "enabled": true, "id": … }`); an array element
583
+ is never invented. For a vendor only the id is derived: its platform,
584
+ identity flag and allowed or blocked events stay authored in the Map, whose
585
+ spec compiler writes them into `campaigns.json`.
493
586
 
494
587
  Each page is bound to one file: the packet's own projection first
495
588
  (`source_html.pages[].page_kit.target_path`, the file the build stage wrote
@@ -501,9 +594,96 @@ the tree's route (`checkout`, `/<slug>/checkout/`) is not a change, and a
501
594
  value nested differently from the tree (`offers/upsell/` against
502
595
  `upsell.html`) is. A routing hint in `sdk_hints.meta_tags` (`next-success-url`,
503
596
  `next-upsell-accept-url`, `next-upsell-decline-url`) that no longer matches
504
- the derived route of the page it names is reported as
505
- `spec.derive.routing_hint_stale` on every run until the Map is re-saved;
506
- hints are a Map projection the editor regenerates and are not rewritten.
597
+ the route of the page it names is reported as
598
+ `spec.derive.routing_hint_stale` on every run until the hint is fixed. The
599
+ list is read off the spec as the run leaves it. Hints are a spec projection
600
+ the Map regenerates, so derive rewrites one only on an edge it re-points after
601
+ handoff (below); any other stale hint is edited in the spec (or, while the Map
602
+ is still the spec's source, fixed by re-saving the Map).
603
+
604
+ A routing hint that is stale is also a doctor blocker (`spec.routing_hint_stale`,
605
+ on every read), and `record spec` refuses to bind a page change while one is
606
+ (`routing_hint_stale`): the built meta tag follows the hint, so a moved upsell
607
+ with checkout's hint on the old path sends shoppers to a 404 after payment.
608
+ The three share one function (`staleRoutingHints` in `src/spec-derive.mjs`).
609
+
610
+ **Pages after handoff (#721).** On a handed-off campaign (see
611
+ [Pages, offers and packages after handoff](#pages-offers-and-packages-after-handoff))
612
+ the page tree also says which pages exist, matched by page id and never by
613
+ position:
614
+
615
+ - A file with a page-kit `page_type` in its frontmatter (`product`, `checkout`,
616
+ `upsell`, `receipt`) that no spec page binds is **added**: its id is the file
617
+ name, its type follows `page_type` (`product` reads as `landing`, `receipt` as
618
+ `thankyou`; a `checkout` file that carries `next_step` is a `checkout_step`
619
+ when it also carries `step_number` and a `select` page when it does not, and
620
+ in a v4 spec a step is a `checkout` that forwards to the Checkout), its
621
+ `page_url` is the route the file builds to, and it is
622
+ appended to the funnel's pages (the legacy `funnel_pages[]` mirror too). The
623
+ diff row carries `op: "insert"`.
624
+ - A page whose file the packet's projection named
625
+ (`source_html.pages[].page_kit.target_path`) and the tree no longer has is
626
+ **removed** (`op: "remove"`). A page the build never wrote stays, reported as
627
+ `page_file_not_found`.
628
+ - Routing is read from the page files' frontmatter links, the keys
629
+ prepare-build projects: `next_url` (an upsell's `on_accept`, a checkout's
630
+ `success_url`, or its `next_page` when it forwards through that, a landing
631
+ page's `next_page`), `decline_url` (an upsell's
632
+ `on_decline`) and, on a select or checkout_step page and on a `checkout`
633
+ page whose file carries `next_step` (a v4 step), `next_step` (`next_page`). An added page takes its own links; a kept page whose link now
634
+ names an added page, or whose routing named a removed page, is re-pointed at
635
+ the page its link names. No other routing is touched. When the re-pointed
636
+ page carries the routing hint that goes with that field
637
+ (`next-upsell-accept-url` for `on_accept`, `next-upsell-decline-url` for
638
+ `on_decline`, `next-success-url` for `success_url`), the hint is rewritten
639
+ to the new target's route in the same spelling (`/<slug>/receipt/` becomes
640
+ `/<slug>/upsell-2/`, `receipt/` becomes `upsell-2/`), and so is the legacy
641
+ `funnel_pages[]` copy. An added page gets no hints and no packages: which
642
+ packages it sells is authored, so derive reports `page_packages_not_derived`
643
+ for it. Until each path into the Checkout passes a page that sells a
644
+ main-cart package (and each upsell or downsell has its offer package),
645
+ doctor blocks on `CheckoutPathHasPackages` or `UpsellHasPackages`.
646
+ - A page whose file is gone and a new file of the same page-kit `page_type`
647
+ in the same run read as a rename (`upsell.html` to `upsell-1.html`, or a
648
+ downsell's `downsell.html`, which is `page_type: upsell`, to
649
+ `downsell-1.html`). Derive applies
650
+ neither, so the page's packages, offers and other authored fields are not
651
+ lost, and reports both as `page_renamed`. For a rename, set the page's
652
+ `page_url` to the new route (it keeps its id; `record spec` binds that as a
653
+ move of routes) or rename the file back. For a new page, remove the old page
654
+ from the spec by hand and derive again.
655
+ - Page order is not something the tree states. Where the funnel's pages carry
656
+ an `order`, the added page's is reported as not derived.
657
+
658
+ Before handoff the files no page binds are listed in `page_scope.unclaimed`
659
+ and nothing is added or removed. The result's `page_scope` names what was
660
+ added, removed and left unclaimed.
661
+
662
+ **Offers and packages from the Campaigns App (#721).** When the packet's
663
+ built `_site/<public_route_slug>/` exists and a public Campaigns API key
664
+ resolves (the packet, its local CampaignSpec, or the declared campaign-key env
665
+ var), `spec derive` makes the read-only `GET {proxy-base}/api/campaign` under
666
+ `X-Campaign-Key` that doctor and `qa run` already make, and refreshes the
667
+ fields the spec mirrors from the live campaign:
668
+
669
+ | Spec field (mirrored) | Live campaign authority |
670
+ |---|---|
671
+ | each page package's `name`, `price`, `price_retail`, `product_name`, `product_sku`, `product_variant_name`, `product_purchase_availability`, `product_inventory_availability`, `image`, the recurring fields `is_recurring`, `price_recurring`, `interval`, `interval_count`, and `variant_attributes` (the campaign's `product_variant_attribute_values`) | the campaign's package with the same `ref_id`; a field the spec lacks is not added when the live value is null, `false` or an empty list, as Map Builder leaves it out |
672
+ | `offers[]` records: `name`, `type`, `code`, `condition`, `benefit`, `packages` | the campaign's offer with the same `ref_id`; a live offer missing from `offers[]` is added |
673
+ | each page's `offers[]` copy: `name`, `type`, `code`, `condition`, `benefit` | the same offer |
674
+
675
+ Which packages and offers a page carries, and a package's `qty`,
676
+ `is_upsell`, `is_order_bump`, `default_selected` and `configurable`, are
677
+ authored and never written. A price reaches the spec only from the Campaigns
678
+ App, never from the repo. A record the spec carries that the live campaign
679
+ does not serve is reported (`package_not_live`, `offer_not_live`) and kept.
680
+ Each row names its source, for example
681
+ `- funnels[0].pages[1].packages[0].price: "49.00" -> "39.00" (from the Campaigns App campaign 424242 (GET /api/campaign))`.
682
+ A read that fails leaves those fields as they are, with a
683
+ `spec.derive.live_campaign_not_read` warning; `--no-live-refs` sends nothing
684
+ and leaves them as they are; `--proxy-base` names the proxy. `--dry-run`
685
+ makes the same read, so its diff shows what would change. The result's
686
+ `live_campaign` says whether the read happened.
507
687
 
508
688
  What the repo cannot state is reported as `not_derived[]` with a reason and
509
689
  the status is `partial` (exit 0; the fields it could derive are written):
@@ -511,20 +691,30 @@ the status is `partial` (exit 0; the fields it could derive are written):
511
691
  | Reason | Meaning |
512
692
  |---|---|
513
693
  | `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 |
514
- | `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 |
694
+ | `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 / a GA4 measurement id (`G-…`) / a digits-only Taboola account id / for the other vendors, one token with no spaces or quotes; 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 |
515
695
  | `waived` | an active named-human `page_kit.sdk_version` waiver covers the exact pair; derive leaves the spec as the waiver accepted it |
516
696
  | `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 |
517
697
  | `page_tree_missing`, `page_file_not_found`, `page_file_ambiguous` | no page tree, no file binds to the page, or more than one does |
518
698
  | `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 |
519
699
  | `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 |
520
700
  | `page_id_duplicate` | the page id appears more than once; no single route can be derived for it |
521
- | `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 |
522
- | `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 |
523
-
524
- A placeholder id (`GTM-XXXXXXX`, a run of one digit) is `target_invalid`:
525
- writing it would declare an analytics contract QA then blocks on. A derived
526
- field the entry does not carry at all is listed under `not_in_target[]` and
527
- left as it is.
701
+ | `spec_container_invalid` | `global_config`, `runtime`, `analytics`, `analytics.providers`, `analytics.providers.<provider>`, `analytics.vendors` or `analytics.vendors.<vendor>` exists in the spec but is not an object; reported by the plan so `--dry-run` and the write agree |
702
+ | `target_empty` | the entry's `gtm_id` / `fb_pixel_id` / vendor id key is empty while the spec declares an id; an empty repo value never deletes a spec id |
703
+ | `page_type_unknown`, `page_id_unusable`, `page_funnel_ambiguous` | after handoff, a page file the spec lacks was not added: its `page_type` is not one of page-kit's four, its file name is not a usable new page id (taken, `index`, or not letters, digits, `-` and `_`), or the spec has several funnels (or none), so which one it joins is not stated |
704
+ | `routing_not_derived`, `routing_target_removed` | an added page's link names no single page (or the file states none), or a kept page routes to a removed page and its file's link names no page the spec keeps; set the field by hand |
705
+ | `page_order_not_derived` | an added page was appended; its `order` is left for you |
706
+ | `page_packages_not_derived` | an added page (any type but a thank-you page) carries no packages; author them before doctor's `CheckoutPathHasPackages` / `UpsellHasPackages` rules pass |
707
+ | `page_renamed` | a page's file is gone and a new file of the same page-kit `page_type` appeared; read as a rename, neither the remove nor the add is applied |
708
+ | `package_not_live`, `offer_not_live`, `offers_not_in_live_campaign` | a package or offer the spec carries is not in the live campaign, or the live campaign carried no offers list; nothing is changed |
709
+
710
+ A placeholder id (`GTM-XXXXXXX`, a run of one digit; for a vendor also
711
+ `XXXX`, `YOUR_`, `CHANGEME`, `<…>` or `EXAMPLE` anywhere in it, such as
712
+ `G-XXXXXXXXXX` or `store.example.com`) is `target_invalid`: writing it would
713
+ declare an analytics contract QA then blocks on, and the template loads a
714
+ vendor for any non-empty value. A derived field the entry does not carry at
715
+ all is listed under `not_in_target[]` and left as it is. Vendors are opt-in
716
+ (an absent key means the vendor is off), so a vendor neither the entry nor
717
+ the spec carries is not listed.
528
718
 
529
719
  `spec derive` writes the spec alone and no longer rebinds identity: the
530
720
  Build Context's `spec.hash` / `spec.material_hash` and the Assembly Report's
@@ -532,12 +722,16 @@ Build Context's `spec.hash` / `spec.material_hash` and the Assembly Report's
532
722
  bound to, so every build, Polish and QA record made against the earlier
533
723
  content reads owed again. Run `record spec` after it (the result's `next`
534
724
  names it) to bind the derived spec; QA's verdict and `bundle check` correlate
535
- against the bound material hash. A derived route change also warns
536
- `spec.derive.projection_stale`: the packet's page-kit projection
537
- (`source_html.pages[].page_kit`) and the Build Context page map were prepared
538
- from the old routes, and `prepare-build` (or `start`) regenerates them. A
725
+ against the bound material hash. A derived route change, or a page added or
726
+ removed, also warns `spec.derive.projection_stale`: the packet's page-kit
727
+ projection (`source_html.pages[].page_kit`) and the Build Context page map
728
+ describe the old pages, and `record spec` re-projects them (see
729
+ [Binding a page change](#binding-a-page-change)). A
539
730
  provider block derive creates warns `spec.derive.analytics_block_created`:
540
- the spec then declares an analytics contract QA gates on. `--dry-run` carries
731
+ the spec then declares an analytics contract QA gates on. A vendor block
732
+ derive creates warns `spec.derive.analytics_vendor_created`: doctor then reads
733
+ that vendor's `campaigns.json` key against the spec, and when the spec had no
734
+ `analytics` block before, QA's analytics leg now runs too. `--dry-run` carries
541
735
  the same `file_reformatted`, `projection_stale` and `build_stale` warnings,
542
736
  phrased as what a write would do.
543
737
 
@@ -1032,6 +1226,109 @@ Record already on disk for this packet's campaign (re-emitted in place), else
1032
1226
  a freshly minted id; `--new-run` mints on request and `--list` prints the ids
1033
1227
  on disk without writing (see docs/workflow-findings-sidecar.md).
1034
1228
 
1229
+ ### Several funnels in one project
1230
+
1231
+ One Page Kit project can hold several funnels, each with its own public route
1232
+ slug (`campaign.public_route_slug`, the `_site/<route>/` directory). Each funnel
1233
+ keeps its own packet and its own records, so setting up, building, recording or
1234
+ running QA for one funnel never changes another funnel's files.
1235
+
1236
+ - The first funnel set up in a project uses the layout above: the packet
1237
+ `campaign-runtime.build.json` and its records directly under
1238
+ `.campaign-runtime/`.
1239
+ - Every later funnel gets a packet named `campaign-runtime-<route>.build.json`
1240
+ at the project root and keeps its records under
1241
+ `.campaign-runtime/funnels/<route>/`. Its packet records that directory as
1242
+ `assembly.runtime_dir`.
1243
+
1244
+ The funnel's runtime directory holds its Build Context, Assembly Report, doctor
1245
+ output, `theme/`, `input/` (normalized brief, Design Source Package, brief
1246
+ payload), `setup-handoff.json`, the committed QA verdict sidecar
1247
+ (`qa-verdict.json`) and its Run Records (`run-records/`). The QA verdict sidecar
1248
+ and Run Records sit under the same relative directory beside the packet, which
1249
+ is the target root unless `--out` placed the packet elsewhere.
1250
+
1251
+ Some things stay shared by the whole project: the run session, the lifecycle,
1252
+ deviation and findings journals, `fetched-specs/`, `agent-context/`,
1253
+ `progress/`, the SDK repin record, the Page Kit build summary, the polish
1254
+ capture images under `.campaign-runtime/polish/` (each file is named by the
1255
+ hash of its bytes, so one funnel's capture never replaces another's), the
1256
+ prepare-build target lock and the full QA verdicts under `qa-output/<map id or
1257
+ route>/`.
1258
+
1259
+ `start` and `prepare-build` choose the packet and directory for you:
1260
+
1261
+ 1. `--out` names the packet when given.
1262
+ 2. Otherwise, a funnel whose route matches `campaign-runtime.build.json`, or a
1263
+ project with no default packet yet, uses the default packet. A funnel that
1264
+ already has `campaign-runtime-<route>.build.json` keeps it. Any other
1265
+ funnel gets `campaign-runtime-<route>.build.json`.
1266
+ 3. A packet that already exists at that path for another route, or whose route
1267
+ cannot be read, is not replaced: the run is refused and names that route.
1268
+ This holds for `--out` too. Only `--force` replaces it, which is how a
1269
+ funnel's route is renamed (below).
1270
+ 4. A packet that already records `assembly.runtime_dir` for this route keeps
1271
+ it, and one for this route that records none keeps `.campaign-runtime/`
1272
+ while those records are its own. Otherwise a funnel whose
1273
+ `.campaign-runtime/funnels/<route>/` exists keeps that directory. The
1274
+ project-level `.campaign-runtime/` is used when it holds no Build Context or
1275
+ Assembly Report yet, or when those belong to this route (the report's
1276
+ `identity.public_route_slug`, else the route of the packet the context
1277
+ names). Records that belong to another route, or whose route cannot be
1278
+ read, are never replaced: the funnel gets its own directory instead.
1279
+
1280
+ The choice is made before the prepare-build target lock is taken and checked
1281
+ again once it is held. When another funnel was set up while the run waited
1282
+ (two first funnels started at once in an empty project), the run stops and
1283
+ names where this funnel now belongs; run it again.
1284
+
1285
+ A route used as a directory and file name may contain letters, digits, dots,
1286
+ underscores and hyphens, and may not start with a dot. A packet whose
1287
+ `assembly.runtime_dir` is anything other than `.campaign-runtime` or
1288
+ `.campaign-runtime/funnels/<its own route>` is refused, because following it
1289
+ could read or write another funnel's records.
1290
+
1291
+ Every command that takes `--packet` reads and writes that funnel's records:
1292
+ `doctor` (including `--ci`, the PR evidence check), `next`, `record`, `theme`,
1293
+ `checkpoint`, `polish capture`, `qa`, `run-record`, `page-kit sync` and
1294
+ `spec derive`. Commands that would otherwise pick a packet for you do not
1295
+ guess when the project holds more than one: `readback <root>` refuses with
1296
+ exit 2 and names each packet with its route, and `tooling status` reads no
1297
+ packet pin and says why. Pass `--packet` to name the funnel. In CI, run the
1298
+ evidence check once per packet. `bundle check` still validates only the
1299
+ repository-root bundle of the first funnel; a later funnel's packet reports
1300
+ `bundle.packet.noncanonical_path`.
1301
+
1302
+ A single-funnel project set up before per-funnel records reads exactly as it
1303
+ did. Its packet records no `assembly.runtime_dir`, so every command keeps
1304
+ reading and writing `.campaign-runtime/`, and nothing has to be set up again.
1305
+ Adding a second funnel to that project leaves the first funnel's packet and
1306
+ records where they are.
1307
+
1308
+ A new route beside an existing default packet could be another funnel or a
1309
+ rename of that one, and only the operator knows which. `prepare-build` sets it
1310
+ up as another funnel and prints a `Funnel:` line saying so, with the steps to
1311
+ make it a rename instead (the JSON result carries `funnelAddedBeside`, the
1312
+ default packet's path and route).
1313
+
1314
+ To rename the route of a project's first funnel (the one at
1315
+ `campaign-runtime.build.json`):
1316
+
1317
+ 1. Set the new route in the CampaignSpec.
1318
+ 2. If setup already ran for the new route, delete what it created:
1319
+ `campaign-runtime-<new route>.build.json` and
1320
+ `.campaign-runtime/funnels/<new route>/`. Do not delete `.campaign-runtime/`
1321
+ itself, which also holds the project's shared records.
1322
+ 3. Run `prepare-build --out campaign-runtime.build.json --force` (or `start`
1323
+ with the same flags). The default packet and the records directly under
1324
+ `.campaign-runtime/` are rewritten for the new route in place. Like any
1325
+ `--force` intake, this clears recorded stage evidence and operator state.
1326
+
1327
+ To rename the route of a later funnel, delete its packet
1328
+ (`campaign-runtime-<old route>.build.json`) and its directory
1329
+ (`.campaign-runtime/funnels/<old route>/`), then run setup again for the new
1330
+ route. It is set up as a funnel named for the new route.
1331
+
1035
1332
  The packet's top-level `generated_at` (ISO-8601 UTC, `Z` suffix) is stamped by
1036
1333
  `prepare-build` on every new packet. Downstream freshness — campaigns-agent's
1037
1334
  readback staleness comparison and its multi-packet selection at the repository
@@ -1042,7 +1339,7 @@ than hand-adding the field. Note that a committed artifact set always reads
1042
1339
  stale to the readback once HEAD moves past it — freshness proof is a
1043
1340
  regeneration at HEAD, not a property a commit can preserve.
1044
1341
 
1045
- 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/`, `progress/`, `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 that set into the target's `.gitignore` (keyed on its marker line; edit the list beneath it freely). An existing block gains `qa-output/` and `.campaign-runtime/progress/` when they are missing, since those hold live URLs, order references and remit records. 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.
1342
+ 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/`, `progress/`, `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 that set into the target's `.gitignore` (keyed on its marker line; edit the list beneath it freely). The block also lists `.campaign-runtime/funnels/*/run-records/` for the Run Records of a project's second and later funnels. An existing block gains `qa-output/`, `.campaign-runtime/progress/` and `.campaign-runtime/funnels/*/run-records/` when they are missing, since those hold live URLs, order references and remit records. 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, at the top level or under `funnels/<route>/`. 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.
1046
1343
 
1047
1344
  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.
1048
1345
 
@@ -1050,8 +1347,11 @@ Add `--ci` for the [standing PR evidence check](campaigns-os-build-flow.md#live-
1050
1347
  `campaigns-os doctor --packet <packet> --ci --no-live-refs`. CI mode assesses the
1051
1348
  same build-bound Polish/QA debt as readback, exits 0 for current evidence, 3 for
1052
1349
  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`,
1350
+ stage returns 3; otherwise an unknown stage returns 4. Advisory Polish, owed
1351
+ for a page-only change, does not count: such a change owes QA on the changed
1352
+ pages only, which `qa run --packet <packet> --page <page_id>` pays. Other doctor errors remain reported but do not
1353
+ change the CI exit. It prints Markdown for a PR comment that opens with the
1354
+ kind of change it found and what that owes, or adds `ci.status`,
1055
1355
  `ci.exit_code`, `ci.polish`, `ci.qa` and `ci.changes` under `--json`.
1056
1356
  `--ci` adds no writes or network reads; pass `--no-live-refs` in CI to disable
1057
1357
  doctor's existing live campaign read. Without `--ci`, inspection behavior is unchanged.
@@ -1252,6 +1552,37 @@ fingerprint, refuses as before and says the manifest is unavailable; run
1252
1552
  `record build` again to record one. Cleaning `_site/` before a build is Page Kit's
1253
1553
  job, not this check's.
1254
1554
 
1555
+ #### What a rebuild changed
1556
+
1557
+ `record build` also keeps two records that let a later check tell a page-only
1558
+ change from a commerce-affecting one
1559
+ ([what a change owes](campaigns-os-build-flow.md#what-a-change-owes)):
1560
+
1561
+ - `stages.assembly.commerce_signature`: `{ "algorithm": "commerce-signature/v1",
1562
+ "campaign_config", "pages" }`. `pages` maps each built page's path to a hash
1563
+ of its commerce-bearing markup (every `data-next-*` attribute and other
1564
+ package, offer, shipping or upsell attribute and `os-checkout-*` attribute,
1565
+ every inline event handler, every `next-*` meta tag, every script's `src` or
1566
+ inline body, every link target and form action). Copy,
1567
+ image references and styling are not in the hash. `campaign_config` is a
1568
+ hash of the campaign's own entry in `_data/campaigns.json`, or
1569
+ `unavailable:<status>` when that entry cannot be read. The record build
1570
+ result does not echo it.
1571
+ - `stages.assembly.build_changes`: one entry per rebuild since the builds Polish
1572
+ and QA last reviewed, oldest first, each `{ "from_build_fingerprint",
1573
+ "build_fingerprint", "recorded_at", "kind", "changed_pages",
1574
+ "unmapped_files", "unmapped_file_count", "reasons" }`. `kind` is `page_only`,
1575
+ `commerce_affecting` or `unknown`, and `reasons` names why a change counts as
1576
+ commerce (for example `checkout_page`, `commerce_markup_changed`,
1577
+ `campaign_config_changed`, `unmapped_file`, `page_added`). Entries no stage
1578
+ needs any more are dropped, and at most 25 are kept; a stage that reviewed an
1579
+ older build reads its change as unknown and owes the full stage.
1580
+
1581
+ Readback's `evidence_debt.<stage>.change` and `doctor --ci` read these, so the
1582
+ changed-page list survives `record build`. A record made before these fields
1583
+ existed has no signature, and its next change reads as unknown (full Polish and
1584
+ QA) until `record build` records one.
1585
+
1255
1586
  A stale or missing Assembly Source Package Fingerprint is waivable only as an
1256
1587
  exceptional Source Freshness Waiver. The waiver must be structured in
1257
1588
  `waivers[]`, with `scope: "assembly_source_package_freshness"` or an
@@ -1785,7 +2116,7 @@ own stages) → calls `next` again → repeat until stage="done"
1785
2116
 
1786
2117
  ### Refreshing after a CampaignSpec change
1787
2118
 
1788
- When the CampaignSpec's material content changes, every build, Polish and QA record made against the earlier content stops counting as current, in local-spec, saved-Map and gateway packets alike. For a saved Map or gateway packet, Campaigns OS compares the copy intake fetched; it does not check whether that copy is the latest remote revision, and reports the remote as unconfirmed. Doctor warns `spec.material_stale`, and `next`, progress, readback, the Assembly Report's own status and the QA gate show the stages that are owed again; `next` routes to the first one. Run `campaigns-os record spec --packet <packet>` to bind the new content: it updates the build context and report identity, re-reads the brief file last saved (by intake or `record brief`), keeps waivers, warning accepts, the applied theme and deploy settings, and moves each superseded stage record into that stage's `history`, which is never used as current proof. `spec derive` no longer rebinds identity; run `record spec` after it. A change to the set, order or routes of active pages still needs intake. Re-recording an unchanged build after a spec change keeps the build owed, because the change has not reached the pages, unless the operator records a reason with `--deviation-reason`; this holds even when `record spec` was not run, and that `record build` also marks Polish and QA owed again. Polish stays owed until a new `polish capture`, with one exception: when `record build --deviation-reason` records the very output a completed Polish reviewed (same build fingerprint and source package) and only the CampaignSpec changed, not the brief's presentation, that `record build` restores the Polish record the change moved to history, stamped with the current inputs. It is the same review of the same pages. QA must produce a new verdict against the current content. Polish measurements of byte-identical output keep their values, but the Polish stage is owed again. These checks are tamper evidence, not proof: a hand-written record that copies the current values, or a packet pointed at an older copy of the spec, is not detected.
2119
+ When the CampaignSpec's material content changes, every build, Polish and QA record made against the earlier content stops counting as current, in local-spec, saved-Map and gateway packets alike. For a saved Map or gateway packet, Campaigns OS compares the copy intake fetched; it does not check whether that copy is the latest remote revision, and reports the remote as unconfirmed. Doctor warns `spec.material_stale`, and `next`, progress, readback, the Assembly Report's own status and the QA gate show the stages that are owed again; `next` routes to the first one. Run `campaigns-os record spec --packet <packet>` to bind the new content: it updates the build context and report identity, re-reads the brief file last saved (by intake or `record brief`), keeps waivers, warning accepts, the applied theme and deploy settings, and moves each superseded stage record into that stage's `history`, which is never used as current proof. `spec derive` no longer rebinds identity; run `record spec` after it. A move of page routes binds with `record spec`, and so does a page added or removed after handoff (see [Binding a page change](#binding-a-page-change)); a change to the order of active pages, or a page added or removed before handoff, still needs intake. Re-recording an unchanged build after a spec change keeps the build owed, because the change has not reached the pages, unless the operator records a reason with `--deviation-reason`; this holds even when `record spec` was not run, and that `record build` also marks Polish and QA owed again. Polish stays owed until a new `polish capture`, with one exception: when `record build --deviation-reason` records the very output a completed Polish reviewed (same build fingerprint and source package) and only the CampaignSpec changed, not the brief's presentation, that `record build` restores the Polish record the change moved to history, stamped with the current inputs. It is the same review of the same pages. QA must produce a new verdict against the current content. Polish measurements of byte-identical output keep their values, but the Polish stage is owed again. These checks are tamper evidence, not proof: a hand-written record that copies the current values, or a packet pointed at an older copy of the spec, is not detected.
1789
2120
 
1790
2121
  The CampaignSpec's material content is the whole spec except `spec_identity`,
1791
2122
  `slug`, `map_id` and `saved_at`; a page `label` edit is material. A spec
@@ -1793,18 +2124,19 @@ change affects the stages as follows:
1793
2124
 
1794
2125
  | Stage | After a spec material change |
1795
2126
  |---|---|
1796
- | `prepare_build` | unchanged (`record spec` refuses a page-scope change, which needs intake) |
2127
+ | `prepare_build` | unchanged (`record spec` refuses a page reorder, and a page added or removed before handoff, which need intake) |
1797
2128
  | `doctor` | recomputed on every read |
1798
2129
  | `setup` | kept |
1799
2130
  | `assembly` | owed: `required`, `required_by: "spec"`, `required_for: ["polish", "qa"]` |
1800
2131
  | `polish` | owed: `required`, `required_by: "spec"`, `required_for: ["qa"]` |
1801
- | `deploy` | kept |
2132
+ | `deploy` | kept, unless a page change alters the set of built pages: then owed (`required`, `required_by: "spec"`, `required_for: ["qa"]`, the probe kept in its history) |
1802
2133
  | `qa` | owed: `required`, `required_by: "spec"`, `required_for: []` |
1803
2134
 
1804
2135
  `record spec` refuses, writing nothing, in this order: `spec_unreadable` (the
1805
2136
  spec does not parse), `spec_identity_changed` (its map id or local spec id
1806
- names another campaign than the packet or the report), `page_scope_changed`,
1807
- then, for the brief file last saved (by intake or `record brief`), `brief_too_large`,
2137
+ names another campaign than the packet or the report), `page_scope_changed`
2138
+ (the active pages are in another order, or a page was added or removed before
2139
+ handoff), the page-change refusals below, then, for the brief file last saved (by intake or `record brief`), `brief_too_large`,
1808
2140
  `brief_source_is_package_artifact` and `brief_file_missing` (the recorded
1809
2141
  path is missing or is not a readable regular file: a directory, a pipe or
1810
2142
  socket, a symlink to nothing, or a file without read permission). When it must
@@ -1817,8 +2149,69 @@ does not record which content it was made against is named in a
1817
2149
  normalized brief from the brief file last saved, so an edit made to the
1818
2150
  normalized brief itself is replaced by what its source gives. A stage whose spec
1819
2151
  stamp already equals the new content (a build recorded after the edit) is not
1820
- demoted. `record spec` does not write the Build Packet. `--dry-run` runs every
1821
- check and writes nothing.
2152
+ demoted. `record spec` writes the Build Packet only when it binds a page
2153
+ change. `--dry-run` runs every check and writes nothing; its `would_write`
2154
+ lists every file the bind would write.
2155
+
2156
+ #### Binding a page change
2157
+
2158
+ `record spec` compares the spec's active pages with the Build Context's
2159
+ `spec.active_pages` in two parts: page identity (the ids, and the order of the
2160
+ pages both lists keep) and routes. It binds:
2161
+
2162
+ - **A move of routes alone**, whenever it happens (#531). This is what a
2163
+ `spec derive` route change needs.
2164
+ - **A page added or removed** on a handed-off campaign (deploy or QA
2165
+ completed, now or in the stage's history; #721). This is what `spec derive`
2166
+ writes when the page tree gains or loses a page after handoff.
2167
+
2168
+ Binding re-projects every page's page-kit target (a page's `next_url` and
2169
+ `decline_url` read the routes of the pages it links to) and writes, in
2170
+ publication order with the report last: the Design Source Package, when a kept
2171
+ page's Surface Identity changes (route, page-kit target or label); the Build
2172
+ Packet's `source_html.pages[]` (an added page as
2173
+ `{ page_id, page_type, origin: "repo", page_kit }`, a removed page's entry
2174
+ dropped) and its `design_source_package` reference; the normalized brief when
2175
+ it is re-derived; the Build Context's `spec.active_pages`, `page_map` and
2176
+ `design_source_package` reference; and the Assembly Report, whose
2177
+ `design_source_package` reference moves with the other two. The package is
2178
+ rewritten at its own path, with a pending provenance record beside it until
2179
+ the report records its new sha256. A run that stops part way leaves a
2180
+ package the next `record spec` or `prepare-build` still knows as its own, and
2181
+ the next `record spec` finishes the job: when that provenance vouches for the
2182
+ package on disk and any of the packet's, the Build Context's or the report's
2183
+ references names other bytes, it moves them (result field
2184
+ `design_source_references_repaired`), and the build, Polish and QA are owed
2185
+ again, since what the stopped run changed cannot be told from what is left. An
2186
+ added page's own page file is its source, so doctor counts it as mapped, and
2187
+ as built once the file exists (`source_html.pages.repo_page_missing` when it
2188
+ does not), and the brief is not asked to name its design authority.
2189
+
2190
+ The stages: build, Polish and QA are owed again as for any spec change. A
2191
+ deploy is bound to the build fingerprint it probed, so the next `record
2192
+ build` makes it owed when the rebuilt site differs. A deploy recorded before
2193
+ that stamp existed carries no fingerprint; `record spec` makes it owed when
2194
+ the set of built page routes changes. A move that changes nothing the
2195
+ build wrote (every page projects to the same target and links, the routing
2196
+ hints are unchanged, the Design Source Package is unchanged, and nothing but
2197
+ `page_url` differs from the material the Build Context recorded in
2198
+ `spec.route_masked_material_hash` at the last bind) demotes nothing: the
2199
+ build, Polish and QA records made against the bound spec are restamped with
2200
+ the new content. A respelling such as `upsell/` to `/<slug>/upsell/` is that
2201
+ case. A Build Context written before this field existed has no recorded value,
2202
+ so its first move is treated as a full change.
2203
+
2204
+ Refused, writing nothing:
2205
+
2206
+ | Code | When |
2207
+ |---|---|
2208
+ | `route_root_changed` | the spec names another route slug, or declares a `route_root` other than the one the packet serves at; the recorded preview and deploy are rooted there, so that needs intake |
2209
+ | `routing_hint_stale` | a page's `sdk_hints.meta_tags` routing hint names a route its page no longer has; edit the hint in the spec first (`spec derive` rewrites the ones on an edge it re-points after handoff) |
2210
+ | `page_kit_target_conflict` | two pages project to one page-kit target file |
2211
+ | `design_source_package_unreadable` | the package the packet names cannot be read |
2212
+ | `design_source_package_operator_supplied` | the new pages would change the package, and the report does not record it as one prepare-build synthesized (`origin` `adopted`, or no `origin` in a report older than that field), so `record spec` does not rewrite it |
2213
+ | `design_source_package_edited` | the new pages would change the package, and it no longer hashes to the sha256 the packet records (or to one its pending provenance record names) |
2214
+ | `design_source_package_unbindable` | the package with the new page surfaces does not validate |
1822
2215
 
1823
2216
  ### Recording stage completion
1824
2217
 
@@ -1828,7 +2221,7 @@ hand-editing `.campaign-runtime/` JSON:
1828
2221
  | Command | Writes | Refused (nothing written) when |
1829
2222
  |---|---|---|
1830
2223
  | `campaigns-os record setup --packet <p>` | Build Context `scaffold.required=false` (`handoff_skill` next-campaigns-build) and `stages.setup` completed | the campaign output directory (`assembly.output_dir`) does not exist, or there is no Build Context or Assembly Report |
1831
- | `campaigns-os record build --packet <p> [--build-environment <development\|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]]` | `stages.assembly` completed with `build_fingerprint` = doctor's `derived.build_output_fingerprint.value`, `source_package_material_fingerprint` = the report's Design Source Package material fingerprint when present, `evidence.build_environment` = the `--build-environment` value when given (kept from the last record otherwise), and the specified scalar `report.adapter_decisions`; all recordable decisions go in one comma-separated flag (a repeated flag keeps only the last list); `wrapper_policy` stays an intake choice. `stages.polish` resets to `required` (`required_by` build, `required_for` qa) unless its evidence is bound to this exact output; a completed `stages.deploy` whose `source_build_fingerprint` names other output resets to `required` the same way, without the old probe's `outputs` and `evidence`, and its prior record is kept in `stages.deploy.history` (a deploy with no `source_build_fingerprint`, recorded before deploy stamped it, is kept) | doctor cannot compute the fingerprint (no `_site/<public_route_slug>/`), setup is still required, `stages.setup` is not terminal, or an adapter decision key/value is invalid |
2224
+ | `campaigns-os record build --packet <p> [--build-environment <development\|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]]` | `stages.assembly` completed with `build_fingerprint` = doctor's `derived.build_output_fingerprint.value`, `source_package_material_fingerprint` = the report's Design Source Package material fingerprint when present, `evidence.build_environment` = the `--build-environment` value when given (kept from the last record otherwise), and the specified scalar `report.adapter_decisions`; all recordable decisions go in one comma-separated flag (a repeated flag keeps only the last list); `wrapper_policy` stays an intake choice. `stages.polish` resets to `required` (`required_by` build, `required_for` qa, `owed_since_build_fingerprint` the build Polish last reviewed) unless its evidence is bound to this exact output; `stages.assembly.commerce_signature` and `stages.assembly.build_changes` record what the rebuild changed (see "What a rebuild changed" below); a completed `stages.deploy` whose `source_build_fingerprint` names other output resets to `required` the same way, without the old probe's `outputs` and `evidence`, and its prior record is kept in `stages.deploy.history` (a deploy with no `source_build_fingerprint`, recorded before deploy stamped it, is kept) | doctor cannot compute the fingerprint (no `_site/<public_route_slug>/`), setup is still required, `stages.setup` is not terminal, or an adapter decision key/value is invalid |
1832
2225
  | `campaigns-os record polish --packet <p> --evidence <file>` | `stages.polish` from the file (`docs/polish-evidence.md` §7: completed, blocked or skipped), bound to doctor's current fingerprint; `report.theme.repair_loop_defect` when the file sets it | build is not recorded for the current output, the file has a shape error (named by field), or, for a completed status, the polish gate doctor evaluates would not pass on the result |
1833
2226
  | `campaigns-os record theme --packet <p>` | `report.theme`: status `applied`, `load_order` `after-next-core`, `css_path`, `commerce_pages` and per-page evidence read from each built commerce page's stylesheet links; any earlier theme waiver is cleared | build is not recorded for the current output, the campaign ships no commerce pages, a built commerce page that loads `next-core.css` does not load `brand-theme.css` (or `checkout-brand.css`) after it or links one missing from the built output, or no built commerce page loads `next-core.css` |
1834
2227
  | `campaigns-os record deploy --packet <p> --base-url <url>` | the packet's `deploy.preview_url` and `stages.deploy` completed with the URL in `outputs`, one evidence line per built page that answered (each page is requested under the URL first) and, on a hosted target, one per script or stylesheet served as built, and `source_build_fingerprint` = the recorded `stages.assembly.build_fingerprint` it probed, so a later `record build` of different output makes deploy owed again and `next` routes back to `record deploy` before QA; a byte-identical rebuild keeps it current | the URL does not name the campaign's route root; on a `local-serve` packet, the URL is not a loopback origin or a built page does not answer 2xx; on any other deploy target, the URL is not https (plain http only on a loopback host), a built page does not answer 200, or a script or stylesheet the built pages load from that origin is missing or served with other bytes (sha256) than the built output (each one named; HTML is not compared, since hosts inject markup); polish is not recorded, the built output changed since build was recorded, or the theme gate is blocked |