@nextcommerce/campaigns-os 1.37.3 → 1.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +114 -10
- package/CHANGELOG.md +708 -0
- package/README.md +44 -31
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4887 -0
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1541 -0
- package/contracts/supported-surface.json +33 -12
- package/docs/build-packet.md +83 -22
- package/docs/campaigns-os-build-flow.md +2 -2
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +350 -0
- package/docs/gateway-login.md +113 -0
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +9 -3
- package/docs/qa-and-test-orders.md +29 -13
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +11 -3
- package/docs/versioning.md +8 -4
- package/package.json +10 -4
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +11 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-readback.v2.schema.json +267 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +179 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +142 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +59 -22
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +53 -28
- package/skills.json +40 -7
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +1178 -270
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/finding-cause.mjs +14 -10
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +96 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/polish-node.mjs +5 -2
- package/src/progress-node.mjs +3 -2
- package/src/progress.mjs +5 -3
- package/src/qa-node.mjs +105 -36
- package/src/qa-publish.mjs +112 -2
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +1937 -0
- package/src/remit.mjs +17 -3
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +4 -1
- package/src/tooling-setup.mjs +160 -0
|
@@ -98,6 +98,11 @@ npm run campaigns-os -- qa resolve --packet campaign-runtime.build.json
|
|
|
98
98
|
|
|
99
99
|
Resolve reads the packet, loads the local CampaignSpec when available, derives deployed page URLs from the packet deploy URL or `--base-url`, probes the entry URLs it derived, and prints the funnel topology. It does not create a verdict.
|
|
100
100
|
|
|
101
|
+
Local-spec QA requires `--packet` and matching `local_spec_id` values in the
|
|
102
|
+
packet, CampaignSpec and Assembly Report, with a current report material hash.
|
|
103
|
+
A Map ID override is refused. See the [local-spec entry](build-packet.md#local-spec-entry)
|
|
104
|
+
for preparation and identity rules.
|
|
105
|
+
|
|
101
106
|
### Route reachability
|
|
102
107
|
|
|
103
108
|
A route set derived from the packet is not evidence that the deployment serves
|
|
@@ -289,7 +294,7 @@ npm run campaigns-os -- qa run \
|
|
|
289
294
|
--base-url https://preview.example.com/campaign/
|
|
290
295
|
```
|
|
291
296
|
|
|
292
|
-
The runner fetches deployed pages, checks route availability, verifies CampaignSpec `sdk_hints.meta_tags` (a key the Campaign Cart SDK does not read, `next-currency` or `next-predictive-address` from `src/sdk-meta-tags.mjs`, is a `warn` row at severity `warn` carrying the shared note, never `manual_review` and never a blocker, whether or not the tag rendered; doctor reports the same key as `sdk_hints.meta_tags.ignored_by_sdk`), writes a local verdict JSON under `<target-repo>/qa-output/<
|
|
297
|
+
The runner fetches deployed pages, checks route availability, verifies CampaignSpec `sdk_hints.meta_tags` (a key the Campaign Cart SDK does not read, `next-currency` or `next-predictive-address` from `src/sdk-meta-tags.mjs`, is a `warn` row at severity `warn` carrying the shared note, never `manual_review` and never a blocker, whether or not the tag rendered; doctor reports the same key as `sdk_hints.meta_tags.ignored_by_sdk`), writes a local verdict JSON under `<target-repo>/qa-output/<qa-storage-key>/<run-id>.json` (the packet's `assembly.target_repo`, else the packet's directory; `--output-dir` overrides it, and a packet-less run uses `qa-output/` under the current directory), and returns exit code `4` when the verdict is blocked. The storage key is the Map ID for saved-Map QA and `local-spec-<local_spec_id>` for local-spec packet QA. The target's managed ignore block lists `qa-output/`, because full verdicts carry live storefront URLs; the committed form is the `.campaign-runtime/qa-verdict.json` projection.
|
|
293
298
|
|
|
294
299
|
### Automatic commercial parity
|
|
295
300
|
|
|
@@ -353,7 +358,7 @@ nothing is ever selected by mtime or "latest":
|
|
|
353
358
|
```bash
|
|
354
359
|
campaigns-os qa promote \
|
|
355
360
|
--packet campaign-runtime.build.json \
|
|
356
|
-
--verdict qa-output/<
|
|
361
|
+
--verdict qa-output/<qa-storage-key>/<run-id>.json \
|
|
357
362
|
--json
|
|
358
363
|
```
|
|
359
364
|
|
|
@@ -381,10 +386,12 @@ Report's `identity.spec_material_hash` and the Build Context's
|
|
|
381
386
|
raw-byte digest of the spec file (the two meanings are deliberate; see
|
|
382
387
|
[docs/migration-sidecar-bundle.md](./migration-sidecar-bundle.md)).
|
|
383
388
|
`campaign_ref_id` is copied from the CampaignSpec's `campaign.ref_id` and
|
|
384
|
-
identifies the platform campaign
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
`
|
|
389
|
+
identifies the configured platform campaign, not this build: two specs for one
|
|
390
|
+
platform campaign share it by design, and it is `null` when the spec carries
|
|
391
|
+
none. `campaign_slug` carries the Map ID for saved-Map verdicts. Local-spec
|
|
392
|
+
verdicts carry explicit `local_spec_id` and use
|
|
393
|
+
`campaign_slug: "local-spec-<local_spec_id>"`; both survive sidecar projection.
|
|
394
|
+
`public_route_slug` is the route, not a substitute for either stable identity.
|
|
388
395
|
|
|
389
396
|
**Trust is stamped by the receiver, never by this CLI.** The QA portal
|
|
390
397
|
receiver accepts verdict posts publicly (after shape/size/rate checks) and
|
|
@@ -512,7 +519,8 @@ Exactly one comparison, against exactly one earlier run:
|
|
|
512
519
|
|
|
513
520
|
1. **Find the previous run.** The most recent Run Record under the Build
|
|
514
521
|
Packet's `.campaign-runtime/run-records/` whose `identity.map_id` matches
|
|
515
|
-
this
|
|
522
|
+
this saved Map, or whose `identity.local_spec_id` matches this local spec
|
|
523
|
+
with no Map ID. Only the first match counts — walking further back to find a
|
|
516
524
|
record that happens to carry usable evidence would compare this run against
|
|
517
525
|
a non-adjacent one and report anything introduced in between as
|
|
518
526
|
pre-existing.
|
|
@@ -624,7 +632,7 @@ QA evidence redacts checkout request bodies and generated QA emails. Verdict art
|
|
|
624
632
|
keep method, URL, response summaries, order refs, line-item summaries, and card last4,
|
|
625
633
|
but they should not contain full customer address/payment payloads.
|
|
626
634
|
|
|
627
|
-
QA runs **publish to the QA portal by default** — they appear in the Campaign Map
|
|
635
|
+
Saved-Map QA runs **publish to the QA portal by default** — they appear in the Campaign Map
|
|
628
636
|
QA tab and the run picker, and the command prints the portal link. No flag needed.
|
|
629
637
|
Pass `--no-post-verdict` (or `--local-only`) for offline / dev / CI runs that should
|
|
630
638
|
stay local-only; publishing never fails the QA run if the portal is unreachable.
|
|
@@ -636,7 +644,14 @@ local-only, and the output names the destination plus the opt-in
|
|
|
636
644
|
(`--post-verdict`, or `campaigns-os telemetry on`). Portal-managed campaigns —
|
|
637
645
|
spec resolved from the portal for the run — keep publish-by-default regardless
|
|
638
646
|
of consent: those verdicts are the QA tab's product surface, not telemetry.
|
|
639
|
-
Explicit flags always win in both directions.
|
|
647
|
+
Explicit flags always win in both directions for saved-Map QA.
|
|
648
|
+
|
|
649
|
+
A packet with `local_spec_id` always keeps its verdict and progress local.
|
|
650
|
+
`qa run` suppresses portal publication even with `--post-verdict`; the flag
|
|
651
|
+
does not turn a local ID into a Map destination. `qa publish` refuses such a
|
|
652
|
+
packet with `refusal.code: local_spec`, including under `--dry-run` or
|
|
653
|
+
`--republish`. Commerce reads, served-page probes and requested typed-card
|
|
654
|
+
orders still run; Run Telemetry follows its existing consent controls.
|
|
640
655
|
|
|
641
656
|
```bash
|
|
642
657
|
npm run campaigns-os -- qa run \
|
|
@@ -646,7 +661,7 @@ npm run campaigns-os -- qa run \
|
|
|
646
661
|
|
|
647
662
|
### Publish a stored verdict (`qa publish`)
|
|
648
663
|
|
|
649
|
-
"
|
|
664
|
+
For saved-Map packets, "run local, publish when clean" is one command, not a rerun. A run kept local
|
|
650
665
|
with `--no-post-verdict` writes the same full verdict under `qa-output/` and
|
|
651
666
|
the same committed sidecar as a publishing run; `qa publish` posts that stored
|
|
652
667
|
verdict to the QA portal through the rail `qa run` uses, without re-running
|
|
@@ -678,6 +693,7 @@ order placed — with a named `refusal.code`:
|
|
|
678
693
|
|
|
679
694
|
| `refusal.code` | What it means |
|
|
680
695
|
|---|---|
|
|
696
|
+
| `local_spec` | The packet has `local_spec_id` and no saved Map destination. Keep the verdict local; moving to a saved Map requires fresh preparation and evidence. |
|
|
681
697
|
| `spec_hash_mismatch` | The verdict's `spec_hash` is not the packet's current spec (`spec.local_path`, hashed the way every spec-identity check hashes it, so `sha256:` prefix and case do not matter). A verdict for a spec that has since changed is not evidence about the current one: re-run `qa run`, which publishes by default. The result carries both hashes. |
|
|
682
698
|
| `spec_hash_absent` | The verdict carries no `spec_hash`. Re-run `qa run`. |
|
|
683
699
|
| `already_published` | The run's Run Record records this verdict's `run_id` as published (by the run itself, or by an earlier `qa publish`). Pass `--republish` to post it again; the existing portal link is in the output either way. |
|
|
@@ -1692,9 +1708,9 @@ a trusted submission attests the runner, not execution or resource identity.
|
|
|
1692
1708
|
|
|
1693
1709
|
### Playwright updates and consumer installs
|
|
1694
1710
|
|
|
1695
|
-
After installing or updating Campaigns OS, run
|
|
1696
|
-
|
|
1697
|
-
installation). This resolves the same Playwright package as QA and polish capture.
|
|
1711
|
+
After installing or updating Campaigns OS, run
|
|
1712
|
+
`npx --no-install campaigns-os qa install-browser` from the campaign project
|
|
1713
|
+
(or `campaigns-os qa install-browser` for a global installation). This resolves the same Playwright package as QA and polish capture.
|
|
1698
1714
|
A project's own `npx playwright install` can resolve a different version and install
|
|
1699
1715
|
a different Chromium build. Campaigns OS is an optional-dependency owner, not a
|
|
1700
1716
|
Playwright peer dependency: npm may share a compatible copy or install a nested one.
|
package/docs/readback.md
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# Run-artifact readback — `campaigns-os readback`
|
|
2
|
+
|
|
3
|
+
`campaigns-os readback <target-repo-root>` projects a read-only view of the
|
|
4
|
+
artifacts a Campaigns OS run has already emitted into a target: the Build
|
|
5
|
+
Packet, the doctor output sidecar, the build context, the assembly report, a QA
|
|
6
|
+
verdict, and a findings export when present. It reads each of those files at
|
|
7
|
+
most once, plus two fixed Git metadata files (the nearest `.git` entry at the
|
|
8
|
+
target or an ancestor, and that Git directory's `logs/HEAD` reflog) for the
|
|
9
|
+
freshness comparison.
|
|
10
|
+
|
|
11
|
+
The safety contract is the point of the command. The readback **writes nothing**
|
|
12
|
+
under the target — not even a lifecycle journal entry, which every other command
|
|
13
|
+
records when `--lifecycle-journal` or `CAMPAIGNS_OS_LIFECYCLE_LOG` is set — and
|
|
14
|
+
it **starts no process** and **touches no network**. A command declared
|
|
15
|
+
read-only that leaves a journal entry behind is not read-only, so `readback` is
|
|
16
|
+
exempted from lifecycle capture the way doctor inspection is.
|
|
17
|
+
|
|
18
|
+
For the same reason the readback resolves **no run session** and sweeps no stale
|
|
19
|
+
one. Its `--packet` names the Build Packet to *project*, not a Build Packet to
|
|
20
|
+
act on, so an active run session — here, at the target, or bound to some other
|
|
21
|
+
packet entirely — never changes what this command reads or what it exits with.
|
|
22
|
+
That also keeps every read bounded: the only reader of a `--packet` file is the
|
|
23
|
+
readback's own 32 MiB-bounded one, which refuses an oversized packet as an
|
|
24
|
+
`unreadable` artifact row rather than loading it.
|
|
25
|
+
|
|
26
|
+
Campaigns OS remains the lifecycle and verdict authority. The readback never
|
|
27
|
+
reinterprets a verdict and never proposes remediation. Where it adds anything
|
|
28
|
+
beyond the artifacts' own words — the contract-static warning labels, the
|
|
29
|
+
fail-to-skip cascade provenance, the staleness assessment — both output modes
|
|
30
|
+
mark that content as the readback's own projection layer.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
campaigns-os readback <target-repo-root> [--json]
|
|
34
|
+
[--packet <path>] [--doctor <path>] [--context <path>]
|
|
35
|
+
[--report <path>] [--qa-verdict <path>] [--findings <path>]
|
|
36
|
+
campaigns-os readback --example [--json]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The default output is the rendered human view. `--json` emits the same
|
|
40
|
+
projection as one `campaigns-os-readback/v2` object on stdout so a caller can
|
|
41
|
+
gate on it; `schemas/campaigns-os-readback.v2.schema.json` is that object's
|
|
42
|
+
shape, and this document is its prose twin. The two modes share one computation
|
|
43
|
+
path: the JSON serializes what the text view already computes and adds no
|
|
44
|
+
interpretation the text view does not also carry.
|
|
45
|
+
|
|
46
|
+
## Exit codes
|
|
47
|
+
|
|
48
|
+
- `0` — any projection the readback could form. An artifact that is missing,
|
|
49
|
+
unreadable, or of an unrecognized shape is a **state the readback reports**,
|
|
50
|
+
not an error: a run whose doctor output is truncated still gets a projection
|
|
51
|
+
saying so.
|
|
52
|
+
- `2` — a caller request that cannot form a projection at all: a target root
|
|
53
|
+
that is not an existing directory, a Build Packet set whose freshness does not
|
|
54
|
+
identify one packet to project (see `packet_selection`), `--example` combined
|
|
55
|
+
with a target or a path override, or a missing/duplicated target argument. The
|
|
56
|
+
reason goes to stderr in one line and no projection is written to stdout.
|
|
57
|
+
|
|
58
|
+
## `--example`
|
|
59
|
+
|
|
60
|
+
`campaigns-os readback --example` projects the synthetic sample bundled with the
|
|
61
|
+
package at `contracts/fixtures/sidecar-bundle/production-shaped/`, in text or
|
|
62
|
+
with `--json`. It takes no target and no path override; combining it with either
|
|
63
|
+
exits `2`. Nothing is written and nothing is copied.
|
|
64
|
+
|
|
65
|
+
The sample is a packaged fixture directory, not a Git checkout, so there is no
|
|
66
|
+
HEAD movement to compare its artifacts against. `--example` says so explicitly
|
|
67
|
+
rather than searching upward for whatever repository the package happens to be
|
|
68
|
+
installed inside: `staleness.computable` is `false`, `head_time` is `null`, and
|
|
69
|
+
`head_detail` reads *"the bundled sample is a packaged fixture directory, not a
|
|
70
|
+
Git checkout: freshness is not computable for it by design"*. Because `clean`
|
|
71
|
+
requires a computable comparison, the sample is `clean: false` — correctly, and
|
|
72
|
+
for exactly the reason the second corollary under [`clean`](#clean) describes.
|
|
73
|
+
Artifact rows report package-relative paths for the same reason: a sample whose
|
|
74
|
+
output differs on every machine is not a sample anyone can check.
|
|
75
|
+
|
|
76
|
+
Its output, verbatim:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
CAMPAIGNS OS RUN-ARTIFACT READBACK
|
|
80
|
+
A read-only projection of this run's emitted artifacts. Campaigns OS
|
|
81
|
+
remains the lifecycle and verdict authority; content marked as the
|
|
82
|
+
readback's own projection layer is interpretation added by this view,
|
|
83
|
+
not by Campaigns OS. This readback proposes no remediation.
|
|
84
|
+
|
|
85
|
+
STALENESS [the readback's own projection layer: EACH loaded artifact's generated_at versus the checkout's HEAD reflog]
|
|
86
|
+
not computable: the bundled sample is a packaged fixture directory, not a Git checkout: freshness is not computable for it by design.
|
|
87
|
+
Treat artifact age as unknown; check the artifacts' generated_at values
|
|
88
|
+
against repository history before reading this view as current.
|
|
89
|
+
|
|
90
|
+
ARTIFACTS
|
|
91
|
+
build packet contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json
|
|
92
|
+
loaded — generated_at 2026-08-24T00:00:00.000Z
|
|
93
|
+
doctor output contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json
|
|
94
|
+
loaded — generated_at 2026-08-24T00:01:00.000Z
|
|
95
|
+
build context contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json
|
|
96
|
+
loaded — generated_at 2026-08-24T00:00:00.000Z
|
|
97
|
+
assembly report contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json
|
|
98
|
+
loaded — generated_at 2026-08-24T00:00:00.000Z
|
|
99
|
+
QA verdict contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json
|
|
100
|
+
loaded — generated_at 2026-08-24T00:04:00.000Z
|
|
101
|
+
findings export contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/findings-export.json
|
|
102
|
+
absent
|
|
103
|
+
|
|
104
|
+
RUN IDENTITY
|
|
105
|
+
map_id = runtime-packet-demo-k9x2 [build packet]
|
|
106
|
+
public_route_slug = runtime-packet-demo [build packet]
|
|
107
|
+
template_family = olympus [build packet]
|
|
108
|
+
qa run_id = MSRBUNDLEFIXTURE000000000000 [QA verdict]
|
|
109
|
+
|
|
110
|
+
STAGES [assembly report; report status: prepared]
|
|
111
|
+
prepare_build completed
|
|
112
|
+
doctor pending
|
|
113
|
+
setup pending
|
|
114
|
+
assembly pending
|
|
115
|
+
polish pending
|
|
116
|
+
deploy pending
|
|
117
|
+
qa pending
|
|
118
|
+
|
|
119
|
+
BUILD CONTEXT [build context; source adapter: html_funnel, status: prepared]
|
|
120
|
+
|
|
121
|
+
DOCTOR [doctor output; status: ready_with_warnings]
|
|
122
|
+
warnings (1) — the contract-static / repo-observed labels are the readback's own projection layer, not doctor's
|
|
123
|
+
repo-observed (1) — repo-observed: reflects this repository, spec, or build as doctor saw it
|
|
124
|
+
deploy.preview_url — Preview URL is not recorded yet.
|
|
125
|
+
doctor's recorded next stage: deploy
|
|
126
|
+
|
|
127
|
+
QA VERDICT [QA verdict; disposition: ready — Campaigns OS is the verdict authority]
|
|
128
|
+
assertions: 0 fail, 1 pass, 0 skipped
|
|
129
|
+
pass http:checkout (family funnel-flow)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## The JSON contract
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"schema_version": "campaigns-os-readback/v2",
|
|
137
|
+
"artifacts": [{ "key": "packet", "path": "...", "state": "loaded", "detail": "" }],
|
|
138
|
+
"packet_selection": {
|
|
139
|
+
"mode": "discovered",
|
|
140
|
+
"signal": "generated_at",
|
|
141
|
+
"selected": "campaign-runtime-second.build.json",
|
|
142
|
+
"candidates_considered": ["campaign-runtime.build.json", "campaign-runtime-second.build.json"],
|
|
143
|
+
"rejected": [{ "path": "campaign-runtime-old.build.json", "reason": "invalid JSON: ..." }]
|
|
144
|
+
},
|
|
145
|
+
"staleness": {
|
|
146
|
+
"computable": true,
|
|
147
|
+
"stale": true,
|
|
148
|
+
"stale_keys": ["report"],
|
|
149
|
+
"unparseable_keys": [],
|
|
150
|
+
"artifacts": {
|
|
151
|
+
"doctor": { "generated_at": "2026-09-01T12:00:00Z", "stale": false },
|
|
152
|
+
"report": { "generated_at": "2026-06-23T00:00:00Z", "stale": true }
|
|
153
|
+
},
|
|
154
|
+
"newest_key": "doctor",
|
|
155
|
+
"head_time": "2026-08-06T07:06:40Z",
|
|
156
|
+
"head_detail": "",
|
|
157
|
+
"artifact_times": { "doctor": "2026-09-01T12:00:00Z", "report": "2026-06-23T00:00:00Z" }
|
|
158
|
+
},
|
|
159
|
+
"doctor": {
|
|
160
|
+
"present": true,
|
|
161
|
+
"status": "ready_with_warnings",
|
|
162
|
+
"error_count": 0,
|
|
163
|
+
"warning_count": 2,
|
|
164
|
+
"warning_groups": { "contract-static": [], "repo-observed": [] }
|
|
165
|
+
},
|
|
166
|
+
"skip_cascades": [{ "blocked_by": "polish.evidence_incomplete", "families": ["meta-tags"] }],
|
|
167
|
+
"divergences": [{ "stage": "polish", "assertion_ids": ["polish.evidence_incomplete"] }],
|
|
168
|
+
"clean": false
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `schema_version`
|
|
173
|
+
|
|
174
|
+
Always the literal `campaigns-os-readback/v2`. A consumer should refuse a
|
|
175
|
+
payload whose `schema_version` it does not recognize rather than reading the
|
|
176
|
+
fields it happens to know.
|
|
177
|
+
|
|
178
|
+
### `artifacts`
|
|
179
|
+
|
|
180
|
+
One record per artifact the readback projected, in the fixed render order
|
|
181
|
+
(`packet`, `doctor`, `context`, `report`, `qa_verdict`, `findings`), restricted
|
|
182
|
+
to the keys the caller asked for. Each record carries:
|
|
183
|
+
|
|
184
|
+
- `key` — the artifact key;
|
|
185
|
+
- `path` — the path the readback read, as resolved from the target root, a
|
|
186
|
+
`--<artifact>` override, or — for `packet` — Build Packet discovery
|
|
187
|
+
(`packet_selection` records which);
|
|
188
|
+
- `state` — one of `loaded`, `absent`, `unreadable`, `unrecognized`;
|
|
189
|
+
- `detail` — the readback's explanation for a non-`loaded` state; the empty
|
|
190
|
+
string for `loaded` and `absent`. One exception: a `loaded` artifact named in
|
|
191
|
+
[`staleness.unparseable_keys`](#staleness) carries the *shape* of the
|
|
192
|
+
`generated_at` value that did not parse (`generated_at is a 12-character
|
|
193
|
+
string that is not an ISO-8601 instant, so this artifact's age could not be
|
|
194
|
+
compared against the checkout`), so a consumer reading `artifacts` alone can
|
|
195
|
+
see that this artifact's age was never established. The value itself is not
|
|
196
|
+
reproduced: a hand-edited or foreign artifact can carry an arbitrarily long
|
|
197
|
+
string there.
|
|
198
|
+
|
|
199
|
+
The artifact's own parsed contents are deliberately not included. A consumer
|
|
200
|
+
that wants an artifact's payload should read that artifact directly; this
|
|
201
|
+
projection would otherwise become an unversioned mirror of every upstream
|
|
202
|
+
Campaigns OS schema.
|
|
203
|
+
|
|
204
|
+
Reads are bounded: 32 MiB for an artifact, 64 KiB for each Git metadata file. A
|
|
205
|
+
file past its bound is refused as `unreadable`, never truncated — a half-read
|
|
206
|
+
artifact would project as malformed JSON and read as the run's fault rather than
|
|
207
|
+
the readback's.
|
|
208
|
+
|
|
209
|
+
A leading UTF-8 byte order mark is **not** stripped, so a BOM-prefixed artifact
|
|
210
|
+
is `unreadable` with an invalid-JSON detail. That is what the Python readback
|
|
211
|
+
this command ports reports for the same bytes — it decodes as plain UTF-8, which
|
|
212
|
+
keeps the mark, and `json.loads` refuses it — and it matters beyond one artifact
|
|
213
|
+
row: a Build Packet the readback cannot parse is not a discovery candidate, so
|
|
214
|
+
the mark cannot decide which packet a run projects.
|
|
215
|
+
|
|
216
|
+
### `packet_selection`
|
|
217
|
+
|
|
218
|
+
How the projected Build Packet was chosen. A target repository can hold more
|
|
219
|
+
than one root-level packet — a suffixed packet beside the default-named one is
|
|
220
|
+
how a second campaign run leaves its record — so without `--packet` the readback
|
|
221
|
+
discovers the candidates (`campaign-runtime.build.json` plus
|
|
222
|
+
`campaign-runtime-*.build.json` at the root) and selects the uniquely freshest.
|
|
223
|
+
|
|
224
|
+
- `mode` — `explicit` when the caller passed `--packet`; `default` when the
|
|
225
|
+
chosen packet is the default-named `campaign-runtime.build.json` (including a
|
|
226
|
+
target with no packet at all, where the default path is still the path
|
|
227
|
+
reported `absent`); `discovered` when freshness selected a suffixed packet.
|
|
228
|
+
- `signal` — what decided it: `explicit`, `sole_candidate`, `generated_at`, or
|
|
229
|
+
`none` (no valid candidate to choose between).
|
|
230
|
+
- `selected` — the file name discovery chose, or `null` when no discovery chose
|
|
231
|
+
one. The full path is the `packet` row's `path` in `artifacts`.
|
|
232
|
+
- `candidates_considered` — the valid candidates discovery compared, default
|
|
233
|
+
first then name order. Empty for `explicit` mode, where no discovery runs.
|
|
234
|
+
- `rejected` — candidates discovery skipped, as `{"path", "reason"}` records.
|
|
235
|
+
Root-level files matching the packet naming land here when they did not parse
|
|
236
|
+
as JSON, were not an object, or did not declare a recognized packet
|
|
237
|
+
`schema_version`. When no valid root candidate exists, a packet sitting only
|
|
238
|
+
at `.campaign-runtime/campaign-runtime.build.json` is also recorded here: that
|
|
239
|
+
sidecar location is not a discovery candidate (the contracted home is the
|
|
240
|
+
repository root, and auto-selecting the sidecar would paper over that
|
|
241
|
+
mismatch), and the reason tells the caller to pass `--packet` to project it.
|
|
242
|
+
|
|
243
|
+
**Freshness is the packet's own `generated_at`, never the file's modification
|
|
244
|
+
time.** An mtime is rewritten by a clone, a checkout, or a copy without any run
|
|
245
|
+
having recorded anything, while `generated_at` is what the emitting run wrote
|
|
246
|
+
down; using it keeps selection a pure function of file contents, so two callers
|
|
247
|
+
reading the same packets always select the same one. When `generated_at` cannot
|
|
248
|
+
single out one packet — two candidates share the newest value, or any candidate
|
|
249
|
+
carries no parseable value — the readback does not choose. It names the
|
|
250
|
+
candidates on stderr, says `--packet` is required, and exits `2`. No JSON object
|
|
251
|
+
is emitted in that case.
|
|
252
|
+
|
|
253
|
+
Values are compared at **microsecond precision** — the precision Python's
|
|
254
|
+
`datetime.fromisoformat` keeps, and the precision the Python readback this
|
|
255
|
+
command ports compared at; a seventh or later fractional digit is truncated, not
|
|
256
|
+
rounded. Two packets whose `generated_at` differ only below the millisecond are
|
|
257
|
+
therefore two instants and not a tie, even though both render identically at the
|
|
258
|
+
second precision every projected view displays.
|
|
259
|
+
|
|
260
|
+
**"Parseable" means exactly what `datetime.fromisoformat` accepts**, here and in
|
|
261
|
+
the staleness comparison, because that is the function the Python readback this
|
|
262
|
+
command ports used — specifically what CPython's C accelerator accepts, which is
|
|
263
|
+
the implementation that runs. A fraction may be any number of digits — the ones
|
|
264
|
+
past the sixth are dropped, not rounded — `,` separates it as readily as `.`, and
|
|
265
|
+
it may follow the hours or the minutes as readily as the seconds (`T10.5`,
|
|
266
|
+
`T10:00.5`). The character between the date and the time is never checked, only
|
|
267
|
+
counted, and it is one Unicode character, so an astral one separates a date from
|
|
268
|
+
a time like any other; a separator with no time behind it (`2026-09-22T`) is
|
|
269
|
+
malformed, while a bare date with no separator at all (`2026-09-22`) is that
|
|
270
|
+
day's midnight.
|
|
271
|
+
|
|
272
|
+
A UTC offset may be `Z`, `±HH`, `±HHMM`, `±HH:MM`, `±HHMMSS` or `±HH:MM:SS`, with
|
|
273
|
+
a fraction of its own, and its magnitude must stay strictly under 24 hours; an
|
|
274
|
+
offset such as `+25:00` is not a large shift but an unreadable value, which makes
|
|
275
|
+
the packet carrying it an unknown candidate and the run a refusal. An offset
|
|
276
|
+
whose **whole-second** part is zero is UTC and its fraction is discarded, so
|
|
277
|
+
`+00:00:00.5` names the same instant `Z` does; an offset with a non-zero
|
|
278
|
+
whole-second part keeps its fraction (`+00:00:01.5` shifts by a second and a
|
|
279
|
+
half). A value with no offset at all is read as UTC.
|
|
280
|
+
|
|
281
|
+
An unparseable `generated_at` is never guessed at. In this selection it makes the
|
|
282
|
+
packet an unknown candidate, which is a refusal; in the staleness comparison it
|
|
283
|
+
keeps the artifact out of the map, neither fresh nor stale.
|
|
284
|
+
|
|
285
|
+
`packet_selection` is `null` only if a programmatic caller builds a payload
|
|
286
|
+
without a selection record; the CLI always supplies one.
|
|
287
|
+
|
|
288
|
+
### `staleness`
|
|
289
|
+
|
|
290
|
+
Whether the artifacts describe the checkout as it is now, assessed **per
|
|
291
|
+
artifact** against the last recorded HEAD movement. The reflog's last entry
|
|
292
|
+
advances on commit, checkout, pull, and reset alike; any of those can invalidate
|
|
293
|
+
a previously emitted artifact, so "HEAD last moved" is deliberately the coarsest
|
|
294
|
+
local signal, not "last commit authored". Instants are ISO-8601 UTC at second
|
|
295
|
+
precision with a `Z` suffix, rendered by the same formatter the text view uses;
|
|
296
|
+
the comparison behind them runs at microsecond precision, as packet selection's
|
|
297
|
+
does. The reflog records whole seconds, so that added precision cannot change
|
|
298
|
+
this verdict either way — it matters only where two artifacts are ordered
|
|
299
|
+
against each other.
|
|
300
|
+
|
|
301
|
+
- `computable` — true when at least one loaded artifact carried a parseable
|
|
302
|
+
`generated_at` **and** the checkout's HEAD reflog was readable;
|
|
303
|
+
- `stale` — true when **any** loaded artifact's `generated_at` predates the last
|
|
304
|
+
recorded HEAD movement. Always `false` when `computable` is false; absence of
|
|
305
|
+
the signal is not evidence of freshness;
|
|
306
|
+
- `stale_keys` — the stale artifact keys, in the fixed render order;
|
|
307
|
+
- `unparseable_keys` — the loaded artifacts that **recorded** a `generated_at`
|
|
308
|
+
this readback could not parse, in the fixed render order. Their age was never
|
|
309
|
+
established, so they are neither fresh nor stale, they are absent from
|
|
310
|
+
`artifacts` and `artifact_times`, and `clean` is `false` while this list is
|
|
311
|
+
non-empty (condition 3 under [`clean`](#clean)). Each one's artifact row
|
|
312
|
+
carries the shape of the refused value in its `detail`. This list does not
|
|
313
|
+
change `computable` or `stale`, which keep their meanings: a set whose only
|
|
314
|
+
comparable artifact is fresh still reports `stale: false`, and the unknown age
|
|
315
|
+
is reported here rather than by widening a field that answers a different
|
|
316
|
+
question;
|
|
317
|
+
- `artifacts` — every loaded artifact that carried a parseable `generated_at`,
|
|
318
|
+
keyed by artifact key, each as `{"generated_at", "stale"}`. An artifact with
|
|
319
|
+
no parseable `generated_at` is neither fresh nor stale: it is absent from this
|
|
320
|
+
map, and if it is the only artifact the assessment is not computable. Where it
|
|
321
|
+
recorded a `generated_at` that did not parse, `unparseable_keys` names it;
|
|
322
|
+
- `newest_key` — the artifact key holding the newest `generated_at`, or `null`
|
|
323
|
+
when not computable. **Information only**: it no longer decides the aggregate;
|
|
324
|
+
- `head_time` — the last recorded HEAD movement, or `null` when the reflog gave
|
|
325
|
+
no usable time;
|
|
326
|
+
- `head_detail` — why `head_time` is `null`; the empty string when it is not;
|
|
327
|
+
- `artifact_times` — every loaded artifact's parseable `generated_at`, keyed by
|
|
328
|
+
artifact key. Carried unchanged for consumers that already read it.
|
|
329
|
+
|
|
330
|
+
Two kinds of missing age are deliberately kept apart, because they say different
|
|
331
|
+
things about the run:
|
|
332
|
+
|
|
333
|
+
- an artifact that **recorded** a `generated_at` this readback could not parse
|
|
334
|
+
claimed an age the readback failed to establish. It is named in
|
|
335
|
+
`unparseable_keys`, its artifact row says so, the text view lists it under
|
|
336
|
+
*UNKNOWN ARTIFACT AGE*, and `clean` is `false`. The parser is a port of
|
|
337
|
+
CPython's `datetime.fromisoformat`, so this is what a hand-edited, foreign or
|
|
338
|
+
corrupt artifact reaches — exactly the case the readback exists to inspect;
|
|
339
|
+
- an artifact with **no `generated_at` key at all** recorded no age, so there is
|
|
340
|
+
no claim about its currency to check. It is simply absent from the comparison,
|
|
341
|
+
it is not named in `unparseable_keys`, and it does not by itself make the
|
|
342
|
+
projection unclean. (If no other artifact carries a parseable `generated_at`,
|
|
343
|
+
the comparison is not computable and `clean` is `false` for that reason
|
|
344
|
+
instead.)
|
|
345
|
+
|
|
346
|
+
`staleness` is `null` only if a programmatic caller builds a payload without an
|
|
347
|
+
assessment; the CLI always supplies one.
|
|
348
|
+
|
|
349
|
+
### `doctor`
|
|
350
|
+
|
|
351
|
+
- `present` — whether a doctor output loaded. When false, `status` is `null` and
|
|
352
|
+
both counts are `0`: those zeros record an absence, not an observation.
|
|
353
|
+
- `status` — doctor's own status string, unreinterpreted.
|
|
354
|
+
- `error_count` — the length of doctor's `errors` list (`0` when the field is
|
|
355
|
+
missing or not a list).
|
|
356
|
+
- `warning_count` — the total number of object-shaped warnings grouped below.
|
|
357
|
+
- `warning_groups` — the readback's own two-way grouping of doctor warnings,
|
|
358
|
+
carrying each warning as doctor wrote it:
|
|
359
|
+
- `contract-static` — codes under the `frontmatter.*` prefixes. These restate
|
|
360
|
+
the template family's shared frontmatter contract and repeat verbatim on
|
|
361
|
+
every doctor pass while that contract is in force. Their persistence does
|
|
362
|
+
not mean a flagged value is still unfixed, and their disappearance is not
|
|
363
|
+
how a fix is confirmed. **A gate must not treat these as repository state.**
|
|
364
|
+
- `repo-observed` — every other code: not in the contract-static table, so its
|
|
365
|
+
message reflects this repository, spec, or build as doctor saw it.
|
|
366
|
+
|
|
367
|
+
The labels are the readback's projection layer, not doctor's own vocabulary.
|
|
368
|
+
|
|
369
|
+
### `skip_cascades`
|
|
370
|
+
|
|
371
|
+
Skipped QA assertions grouped by the failure that blocked them, derived from
|
|
372
|
+
each skipped assertion's `evidence.blocked_by`. One record per distinct blocker,
|
|
373
|
+
in first-seen order:
|
|
374
|
+
|
|
375
|
+
- `blocked_by` — the recorded blocking assertion, or the literal
|
|
376
|
+
`(no blocked_by recorded)` when the verdict named none;
|
|
377
|
+
- `families` — the skipped assertions' families, falling back to the assertion
|
|
378
|
+
id and then to `(unnamed)`.
|
|
379
|
+
|
|
380
|
+
Empty when no QA verdict loaded or none of its assertions were skipped. This
|
|
381
|
+
grouping is the readback's own projection layer.
|
|
382
|
+
|
|
383
|
+
### `divergences`
|
|
384
|
+
|
|
385
|
+
Where two artifacts record different states for the same stage: the assembly
|
|
386
|
+
report calls a stage `completed` while the QA verdict fails an assertion whose
|
|
387
|
+
id is namespaced to that stage. One record per stage, in the order the failures
|
|
388
|
+
were seen:
|
|
389
|
+
|
|
390
|
+
- `stage` — the stage name;
|
|
391
|
+
- `assertion_ids` — the failing QA assertion ids attributed to it.
|
|
392
|
+
|
|
393
|
+
The readback reports the disagreement and does not adjudicate it; both records
|
|
394
|
+
stand as written. Empty when no verdict loaded, no assertion failed, or no
|
|
395
|
+
failure lines up with a completed stage. This too is the readback's own layer.
|
|
396
|
+
|
|
397
|
+
## `clean`
|
|
398
|
+
|
|
399
|
+
`clean` is true if and only if **all five** of the following hold:
|
|
400
|
+
|
|
401
|
+
1. **Every artifact the readback found is loaded and recognized.** Formally: no
|
|
402
|
+
artifact is in state `unreadable` or `unrecognized`. An `absent` artifact
|
|
403
|
+
does not by itself make the projection unclean — a run that emitted no
|
|
404
|
+
findings export or no QA verdict has not thereby failed, and the readback
|
|
405
|
+
does not decide which artifacts a run owes. An artifact that exists but
|
|
406
|
+
cannot be read, or whose schema is not recognized, always makes it unclean,
|
|
407
|
+
because the readback cannot see what that artifact says.
|
|
408
|
+
2. **Staleness is computable and no loaded artifact is stale.** Both halves are
|
|
409
|
+
required. An uncomputable comparison is not clean: the readback cannot show
|
|
410
|
+
that the artifacts describe the current checkout, and an unknown age must
|
|
411
|
+
never read as a fresh one. Since `stale` is now the any-artifact aggregate, a
|
|
412
|
+
target with one stale artifact and five fresh ones is not clean.
|
|
413
|
+
3. **No loaded artifact recorded a `generated_at` the readback could not
|
|
414
|
+
parse.** Formally: `staleness.unparseable_keys` is empty. Such an artifact
|
|
415
|
+
leaves the comparison — it is neither fresh nor stale — so without this
|
|
416
|
+
condition a fresh sibling carried the aggregate and an artifact whose
|
|
417
|
+
currency was never established shipped inside a `clean: true` payload. That
|
|
418
|
+
is the same "an unknown age must never read as a fresh one" rule as condition
|
|
419
|
+
2, applied per artifact rather than to the comparison as a whole. An artifact
|
|
420
|
+
that recorded **no** `generated_at` at all is not covered by this condition:
|
|
421
|
+
it made no claim about its age, so there is nothing here that the readback
|
|
422
|
+
failed to check.
|
|
423
|
+
4. **`divergences` is empty.**
|
|
424
|
+
5. **`doctor.error_count` is zero.**
|
|
425
|
+
|
|
426
|
+
Doctor warnings — of either group — do not affect `clean`. Neither does a
|
|
427
|
+
blocked QA verdict, a blocked assembly report, or a non-empty `skip_cascades`.
|
|
428
|
+
|
|
429
|
+
### What `clean` does and does not mean
|
|
430
|
+
|
|
431
|
+
`clean` is a statement about the readback's own view, not a verdict on the
|
|
432
|
+
campaign. It means: the readback read every artifact that was there, understood
|
|
433
|
+
all of them, can show — for every artifact that recorded an age — that none of
|
|
434
|
+
them is older than the checkout, found no contradiction between them, and saw no
|
|
435
|
+
doctor error. Campaigns OS remains the
|
|
436
|
+
lifecycle and verdict authority; the readback never reinterprets a verdict.
|
|
437
|
+
|
|
438
|
+
The practical consequence for a caller: `clean: true` says the artifacts are
|
|
439
|
+
trustworthy enough to read, not that the run succeeded. A run whose QA verdict
|
|
440
|
+
is `blocked` can be `clean: true`, and correctly so — the readback saw exactly
|
|
441
|
+
what Campaigns OS recorded, including the block. A gate that wants "the campaign
|
|
442
|
+
passed" must read the QA verdict itself; `clean` is the precondition that makes
|
|
443
|
+
reading it meaningful.
|
|
444
|
+
|
|
445
|
+
Two corollaries worth stating because they surprise people:
|
|
446
|
+
|
|
447
|
+
- A target with no artifacts at all is never `clean: true`. Every artifact is
|
|
448
|
+
`absent`, so condition 1 passes, but no artifact carries a `generated_at`,
|
|
449
|
+
staleness is not computable, and condition 2 fails.
|
|
450
|
+
- A target whose artifacts are fine but which is not a Git checkout is never
|
|
451
|
+
`clean: true`, for the same reason: staleness has no HEAD movement to compare
|
|
452
|
+
against. The bundled `--example` sample is exactly this case.
|
|
453
|
+
- A target carrying one artifact whose recorded `generated_at` the readback
|
|
454
|
+
cannot parse is never `clean: true`, even when every artifact it *can* read is
|
|
455
|
+
newer than the checkout and `stale` is `false`: condition 3 fails, and
|
|
456
|
+
`unparseable_keys` names the artifact. An artifact that recorded no
|
|
457
|
+
`generated_at` at all does not trip that condition — it is the absence of a
|
|
458
|
+
claim, not an unverified one.
|
|
459
|
+
|
|
460
|
+
## What changed from v1 (and why the version moved)
|
|
461
|
+
|
|
462
|
+
The readback began life outside this repository, emitting
|
|
463
|
+
`campaigns-agent-readback/v1`. This command is that module's port into the
|
|
464
|
+
kernel, and it carries one behaviour fix — assessed per artifact — together with
|
|
465
|
+
the `clean` rule that keeps an artifact of unknown age from riding along on a
|
|
466
|
+
fresh sibling.
|
|
467
|
+
|
|
468
|
+
**v1 assessed staleness from the newest artifact only.** It found the loaded
|
|
469
|
+
artifact with the latest `generated_at` and compared that one instant against
|
|
470
|
+
HEAD. The consequence: re-running any single stage refreshed one artifact, and
|
|
471
|
+
every older sibling — an assembly report from three HEAD movements ago, a QA
|
|
472
|
+
verdict from before the last merge — was reported as part of a fresh set.
|
|
473
|
+
`stale: false` and `clean: true` were both reachable for a target whose assembly
|
|
474
|
+
report predated the checkout it claimed to describe, which is the exact
|
|
475
|
+
condition the field exists to surface.
|
|
476
|
+
|
|
477
|
+
**v2 assesses every loaded artifact.** Each artifact with a parseable
|
|
478
|
+
`generated_at` gets its own `stale` verdict in `staleness.artifacts`,
|
|
479
|
+
`stale_keys` names the stale ones in render order, the aggregate `staleness.stale`
|
|
480
|
+
is true when any of them is stale, and the text view names each stale artifact
|
|
481
|
+
rather than only the newest one. `newest_key` is kept but demoted to
|
|
482
|
+
information; `artifact_times` is kept unchanged.
|
|
483
|
+
|
|
484
|
+
**v2 also refuses to call an unknown age a fresh one.** Assessing every artifact
|
|
485
|
+
left one way for the old answer to survive: an artifact whose recorded
|
|
486
|
+
`generated_at` does not parse leaves the comparison entirely, so with a fresh
|
|
487
|
+
sibling beside it the aggregate found nothing stale and the projection reported
|
|
488
|
+
`clean: true` — for a set containing an artifact whose currency was never
|
|
489
|
+
established. v2 adds `staleness.unparseable_keys`, which names those artifacts
|
|
490
|
+
in render order; their artifact rows carry the shape of the value that did not
|
|
491
|
+
parse, the text view lists them under *UNKNOWN ARTIFACT AGE*, and `clean` is
|
|
492
|
+
`false` whenever the list is non-empty. `computable` and `stale` are unchanged —
|
|
493
|
+
the unknown age is reported in its own field rather than folded into one that
|
|
494
|
+
answers a different question — and an artifact carrying no `generated_at` key at
|
|
495
|
+
all keeps its previous behaviour: out of the comparison, and not by itself
|
|
496
|
+
unclean.
|
|
497
|
+
|
|
498
|
+
That is a change of meaning in a published field — a `stale` a consumer already
|
|
499
|
+
gates on now answers a different question — and `docs/versioning.md` makes that
|
|
500
|
+
a breaking change to a machine-readable contract requiring a new schema version
|
|
501
|
+
rather than a silent edit. Hence `campaigns-os-readback/v2`. Everything else in
|
|
502
|
+
the payload keeps its v1 field names and meanings; `unparseable_keys` is a new
|
|
503
|
+
field, and `clean` — already v2's own flag — states the rule above.
|
|
504
|
+
|
|
505
|
+
Migration for a consumer already reading the v1 payload:
|
|
506
|
+
|
|
507
|
+
- Accept `campaigns-os-readback/v2` instead of `campaigns-agent-readback/v1`.
|
|
508
|
+
- Nothing else needs to change to keep working: `stale` is still a boolean in
|
|
509
|
+
the same place, and it is now true strictly more often (it is true whenever v1
|
|
510
|
+
said true, plus the cases v1 missed). A gate that refused stale artifacts
|
|
511
|
+
refuses strictly more of them; a gate that relied on the v1 answer to pass was
|
|
512
|
+
relying on the defect.
|
|
513
|
+
- To report *which* artifacts are stale rather than only that some are, read
|
|
514
|
+
`stale_keys` or `artifacts`.
|
|
515
|
+
- A gate that reads `clean` needs no change either, and now refuses one more
|
|
516
|
+
case: a set containing an artifact whose recorded age the readback could not
|
|
517
|
+
parse. To report which artifact that is, read `unparseable_keys`.
|
|
518
|
+
|
|
519
|
+
## Related
|
|
520
|
+
|
|
521
|
+
- [`schemas/campaigns-os-readback.v2.schema.json`](../schemas/campaigns-os-readback.v2.schema.json) — the payload's shape.
|
|
522
|
+
- [`docs/supported-surface.md`](supported-surface.md) — what this command's surface commitment means.
|
|
523
|
+
- [`docs/versioning.md`](versioning.md) — why a changed field meaning takes a new schema version.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
How a checkout of this repository at one commit becomes a usable installed runtime, and how a consumer decides whether a prepared one is still trustworthy. Everything below is generated from `contracts/runtime-recipe.campaigns-os-node-v1.json`, which is the only authority for these values.
|
|
10
10
|
|
|
11
|
-
Recipe kind `campaigns-os-node-v1`, revision `1.0.2`, validated by `schemas/campaigns-os-runtime-recipe.v1.schema.json` (`Campaigns OS Runtime Recipe v1`). Supported surface at generation time: `1.
|
|
11
|
+
Recipe kind `campaigns-os-node-v1`, revision `1.0.2`, validated by `schemas/campaigns-os-runtime-recipe.v1.schema.json` (`Campaigns OS Runtime Recipe v1`). Supported surface at generation time: `1.43.1`.
|
|
12
12
|
|
|
13
13
|
## What this is
|
|
14
14
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Run the read-only source scanner from a campaign repository before changing its SDK pin:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npx campaigns-os sdk storage-check --target . --target-sdk 0.4.38 --manifest /path/to/campaign-cart/docs/compatibility/storage-migrations.v1.json --scope campaigns/spring,shared --json
|
|
6
|
+
npx --no-install campaigns-os sdk storage-check --target . --target-sdk 0.4.38 --manifest /path/to/campaign-cart/docs/compatibility/storage-migrations.v1.json --scope campaigns/spring,shared --json
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Omit `--json` for the concise human report. Exit 0 means source-compatible; exit 2 means incompatible or unknown; invalid arguments or manifests exit 1. No merchant files or pins are rewritten. This is independent of doctor's built HTML markup check.
|