@nextcommerce/campaigns-os 1.37.3 → 1.41.2

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 (49) hide show
  1. package/AGENTS.md +114 -10
  2. package/CHANGELOG.md +530 -0
  3. package/README.md +38 -27
  4. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  5. package/contracts/effects.v1.json +4794 -0
  6. package/contracts/release-ledger.json +789 -0
  7. package/contracts/supported-surface.json +25 -5
  8. package/docs/build-packet.md +27 -16
  9. package/docs/demo-preview.md +1 -1
  10. package/docs/diagnostics.md +7 -4
  11. package/docs/effects.md +281 -0
  12. package/docs/gateway-login.md +113 -0
  13. package/docs/orientation-contract-reference.md +4 -1
  14. package/docs/progress-snapshots.md +3 -3
  15. package/docs/qa-and-test-orders.md +3 -3
  16. package/docs/readback.md +523 -0
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/sdk-storage-compatibility.md +1 -1
  19. package/docs/skills-revision.md +364 -0
  20. package/docs/supported-surface.md +11 -3
  21. package/docs/versioning.md +8 -4
  22. package/package.json +8 -3
  23. package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
  24. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  25. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  26. package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
  27. package/skills/campaign-readback-classification/SKILL.md +230 -0
  28. package/skills/campaign-run-evidence/SKILL.md +140 -0
  29. package/skills/contribution-intake/SKILL.md +85 -0
  30. package/skills/next-campaigns-build/SKILL.md +33 -12
  31. package/skills/next-campaigns-os/SKILL.md +45 -21
  32. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  33. package/skills/next-campaigns-polish/SKILL.md +43 -17
  34. package/skills/next-campaigns-qa/SKILL.md +48 -24
  35. package/skills.json +39 -6
  36. package/src/admin-transport.mjs +123 -0
  37. package/src/cli.mjs +991 -200
  38. package/src/credential-store.mjs +183 -0
  39. package/src/deviation.mjs +3 -2
  40. package/src/diagnostic.mjs +4 -1
  41. package/src/gate-actions.mjs +2 -2
  42. package/src/install-mode.mjs +17 -9
  43. package/src/lifecycle.mjs +95 -0
  44. package/src/login.mjs +152 -0
  45. package/src/package-install-fixture.mjs +3 -2
  46. package/src/qa-node.mjs +56 -19
  47. package/src/qa-publish.mjs +108 -2
  48. package/src/readback.mjs +1936 -0
  49. package/src/remit.mjs +17 -3
@@ -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.37.3`.
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.41.2`.
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.