@nextcommerce/campaigns-os 1.48.0 → 1.52.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 (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -0,0 +1,1389 @@
1
+ // Tracking parameters reach the order: during a browser test order QA already
2
+ // places, seed synthetic attribution values on the runner's own loads before
3
+ // the campaign navigates, then report two results separately:
4
+ // - URL preservation (tracking.url): whether each seeded value is still in the
5
+ // address on every page navigation observed, through to the navigation
6
+ // after the order;
7
+ // - order attribution (tracking.order): whether the credited attribution field
8
+ // reached the request of an order the store accepted.
9
+ // Declared <meta> tracking tags get one tracking.tag row each: the markup
10
+ // literal against the request's attribution metadata.
11
+ //
12
+ // The observer here is fed by listeners registered in qa-browser.mjs
13
+ // (captureCheckoutEvents and gotoAndSettle). It keeps raw values in memory
14
+ // only: the observation it persists holds the synthetic seed table, origin+path
15
+ // hop paths, equality outcomes, declared tag names and the sha256 of each tag
16
+ // literal. No query string, request or response body, attribution metadata or
17
+ // landing page is ever stored. Every extractor is wrapped: an exception records
18
+ // extractor_failed on its row and never breaks the order.
19
+ //
20
+ // rederiveQcResult(observation) is the rule the QC readers call; the producer
21
+ // builds every row through it, so a stored row only stands when its stored
22
+ // observation re-derives to the same result.
23
+ //
24
+ // SDK pin. The credited-field aliases, test-order attribution and tag grammar
25
+ // follow campaign-cart 12ba5d14: src/core/attribution/attribution-collector.ts
26
+ // lines 18-59 (credited fields and aliases) and 361-392 (tracking tags), and
27
+ // src/features/checkout/builders/order-builder.ts lines 172-216 (test-order
28
+ // attribution). Both line ranges are unchanged at the v0.4.38 tag, the pin of
29
+ // the vendored attribute index (src/sdk-attribute-index.mjs).
30
+ import { createHash, randomBytes } from "node:crypto";
31
+
32
+ import { aggregateQcResults, buildQcResult, toQaAssertion } from "./qc-results.mjs";
33
+ import { REDACTED_QUERY, TRUNCATED, redactUrlQueriesInText, redactUrlQuery } from "./qa-url-privacy.mjs";
34
+
35
+ export const TRACKING_CHECKS = Object.freeze(["tracking.url", "tracking.order", "tracking.tag"]);
36
+ const ROW_KEY = Object.freeze({ "tracking.url": "url", "tracking.order": "order", "tracking.tag": "tag" });
37
+ const FAMILY = "browser-test-order";
38
+
39
+ // Credited order field -> the URL names the SDK reads for it, preferred first
40
+ // (affid beats aff; the long subaffiliate form beats the short one).
41
+ export const CREDITED_FIELD_ALIASES = Object.freeze({
42
+ affiliate: Object.freeze(["affid", "aff"]),
43
+ funnel: Object.freeze(["funnel"]),
44
+ gclid: Object.freeze(["gclid"]),
45
+ utm_source: Object.freeze(["utm_source"]),
46
+ utm_medium: Object.freeze(["utm_medium"]),
47
+ utm_campaign: Object.freeze(["utm_campaign"]),
48
+ utm_content: Object.freeze(["utm_content"]),
49
+ utm_term: Object.freeze(["utm_term"]),
50
+ subaffiliate1: Object.freeze(["subaffiliate1", "sub1"]),
51
+ subaffiliate2: Object.freeze(["subaffiliate2", "sub2"]),
52
+ subaffiliate3: Object.freeze(["subaffiliate3", "sub3"]),
53
+ subaffiliate4: Object.freeze(["subaffiliate4", "sub4"]),
54
+ subaffiliate5: Object.freeze(["subaffiliate5", "sub5"]),
55
+ });
56
+
57
+ // The published default seed policy: one URL name per credited field below.
58
+ export const DEFAULT_SEEDED_URL_NAMES = Object.freeze(["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "affid", "sub1", "subaffiliate2"]);
59
+ // Credited fields the default policy leaves unseeded; each is an explicit
60
+ // excluded / not_seeded_by_policy order member unless tracking.preserve opts
61
+ // it in (funnel and gclid never can: they are on the never-seeded list).
62
+ export const NOT_SEEDED_BY_DEFAULT = Object.freeze(["funnel", "gclid", "subaffiliate3", "subaffiliate4", "subaffiliate5"]);
63
+ const NEVER_SEEDED = Object.freeze(["funnel", "reset", "test", "debugger", "payment_failed", "payment_method", "gclid", "fbclid", "clickid", "evclid"]);
64
+ export const MAX_SEEDED_KEYS = 16;
65
+ const UTM_FIELDS = Object.freeze(["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"]);
66
+ const ORDER_SOURCES = Object.freeze(["request", "create_response", "readback"]);
67
+ const OUTCOMES = Object.freeze(["equal", "differs", "absent"]);
68
+ const CREATE_OUTCOMES = Object.freeze(["accepted", "rejected", "failed", "none"]);
69
+ const EXTRACTORS = Object.freeze(["hop_equality", "request_equality", "response_equality", "tag_dom_read", "page_script_scan"]);
70
+ const PAGE_SCRIPT_CALLS = Object.freeze(["setAttribution(", "addMetadata(", "setMetadata("]);
71
+ const SCRIPT_SCAN_MAX_BYTES = 1024 * 1024;
72
+ // Everything the observer makes an attempt wait for (document reads the
73
+ // runner awaits, the create-response read, the final settle) shares this one
74
+ // budget, so tracking adds at most this much to an attempt.
75
+ export const TRACKING_ADDED_BOUND_MS = 1000;
76
+ // A read nothing awaits still gives up after this long.
77
+ const DOCUMENT_READ_TIMEOUT_MS = 1000;
78
+ const SDK_TEST_UTM_SOURCE = "konami_code";
79
+ // The campaign's own SDK loader, campaign-cart@v<version>/dist/loader.js; only
80
+ // the package and version are kept.
81
+ const LOADER_PIN_PATTERN = /campaign-cart@(v?\d+(?:\.\d+){0,2}(?:-[0-9a-z.]+)?)\/dist\/loader\.js/i;
82
+ const LOADER_PIN_VALUE = /^campaign-cart@v?\d+(?:\.\d+){0,2}(?:-[0-9a-z.]+)?$/i;
83
+
84
+ export function loaderPinOf(url) {
85
+ const match = LOADER_PIN_PATTERN.exec(String(url || ""));
86
+ return match ? `campaign-cart@${match[1]}` : null;
87
+ }
88
+
89
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
90
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
91
+ const sha256 = (text) => `sha256:${createHash("sha256").update(String(text)).digest("hex")}`;
92
+ // A rendered tag's name is page text: it is persisted cut at its first query
93
+ // (qa-url-privacy.mjs). The projection of a non-blank name is never blank.
94
+ const persistedTagName = (name) => redactUrlQueriesInText(name);
95
+ // A hop's origin+path, with no query left in it in any encoding (a path can
96
+ // carry an encoded "?"). Persisted exits project rows again with the same
97
+ // projection, which leaves these unchanged.
98
+ const persistedPath = (url) => redactUrlQueriesInText(redactUrlQuery(url));
99
+
100
+ // The attempt's raw observation, kept on the runner's in-memory result under a
101
+ // symbol key so it is never serialized.
102
+ export const TRACKING_OBSERVATION = Symbol("campaigns-os.tracking-observation");
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Markers
106
+
107
+ // The rows each extractor feeds: a failed extractor bars a pass on each.
108
+ const EXTRACTOR_ROWS = Object.freeze({
109
+ hop_equality: Object.freeze(["tracking.url"]),
110
+ request_equality: Object.freeze(["tracking.order", "tracking.tag"]),
111
+ response_equality: Object.freeze(["tracking.order"]),
112
+ tag_dom_read: Object.freeze(["tracking.tag"]),
113
+ page_script_scan: Object.freeze(["tracking.order", "tracking.tag"]),
114
+ });
115
+ const SHA256_LITERAL = /^sha256:[a-f0-9]{64}$/;
116
+ const isSeq = (value) => Number.isInteger(value) && value >= 0;
117
+ const oneOf = (values) => (value) => values.includes(value);
118
+
119
+ // The one closed schema of the status and completeness markers of a 1.1
120
+ // observation. Capture (finalize) and re-derivation (readObservation) both
121
+ // check every marker against it:
122
+ // at where it lives: the observation, every hop, every seeded key of
123
+ // a hop's params, every order field entry, every name entry;
124
+ // valid its vocabulary; a missing, null, unknown or ill-typed value is
125
+ // outside it and never reads as a success value (`optional`:
126
+ // absence is judged by `valid` too);
127
+ // complete the valid values that may contribute to a pass;
128
+ // failure what an invalid value is recorded as, where the marker has one
129
+ // (never a complete value);
130
+ // extractor the extractor an invalid value fails, where it has no failure
131
+ // value (a function of the entry for an order field outcome);
132
+ // feeds the rows it feeds; a value that is invalid, or valid but not
133
+ // complete, bars a pass on each, with `reason` (`bars`: only
134
+ // the rows a listed extractor feeds);
135
+ // producer the markers the producer adds beside the contract's fields.
136
+ // Every observation finalize writes carries every marker listed here.
137
+ //
138
+ // A marker whose complete value depends on a read starts in a value that is
139
+ // not complete (tag_read "not_run", page_script_involved and
140
+ // sdk_test_attribution null) and takes a complete value only when that read
141
+ // completes, so an attempt whose read never ran never reads as one whose read
142
+ // found nothing.
143
+ export const OBSERVATION_MARKERS = Object.freeze({
144
+ extractor_failed: Object.freeze({
145
+ at: "observation", key: "extractor_failed", producer: true,
146
+ valid: (value) => Array.isArray(value) && value.every((name) => EXTRACTORS.includes(name)),
147
+ complete: (value) => value.length === 0,
148
+ failure: () => [...EXTRACTORS],
149
+ feeds: TRACKING_CHECKS, bars: (value) => [...new Set(value.flatMap((name) => EXTRACTOR_ROWS[name]))], reason: "extractor_failed",
150
+ }),
151
+ // "not_run": no document read completed, so no tag list was read.
152
+ tag_read: Object.freeze({
153
+ at: "observation", key: "tag_read", producer: true,
154
+ valid: oneOf(["read", "failed", "not_run"]), complete: oneOf(["read"]), failure: () => "failed",
155
+ feeds: ["tracking.tag"], reason: "extractor_failed",
156
+ }),
157
+ request_read: Object.freeze({
158
+ at: "observation", key: "request_read", producer: true,
159
+ valid: oneOf(["parsed", "unreadable", "none"]), complete: oneOf(["parsed"]), failure: () => "unreadable",
160
+ feeds: ["tracking.order", "tracking.tag"], reason: (value) => (value === "none" ? "no_accepted_order" : "request_body_unreadable"),
161
+ }),
162
+ // null: no create request body was parsed, so the marker was never read.
163
+ sdk_test_attribution: Object.freeze({
164
+ at: "observation", key: "sdk_test_attribution", producer: true,
165
+ valid: (value) => value === null || value === true || value === false, complete: oneOf([false]), failure: () => true,
166
+ feeds: ["tracking.order"], reason: "sdk_test_attribution",
167
+ }),
168
+ recovered: Object.freeze({
169
+ at: "observation", key: "recovered", producer: true,
170
+ valid: oneOf([true, false]), complete: oneOf([false]), failure: () => true,
171
+ feeds: ["tracking.url", "tracking.order"], reason: "attempt_recovered_on_new_page",
172
+ }),
173
+ create: Object.freeze({
174
+ at: "observation", key: "create",
175
+ valid: oneOf(CREATE_OUTCOMES), complete: oneOf(["accepted"]), failure: () => "none",
176
+ feeds: TRACKING_CHECKS, reason: { "tracking.url": "attempt_incomplete", "tracking.order": "no_accepted_order", "tracking.tag": "no_accepted_order" },
177
+ }),
178
+ attribution_object: Object.freeze({
179
+ at: "observation", key: "attribution_object",
180
+ valid: oneOf([true, false]), complete: oneOf([true]), failure: () => false,
181
+ feeds: ["tracking.order"], reason: "attribution_not_sent",
182
+ }),
183
+ // Both booleans are complete observations (true is judged as page-script
184
+ // involvement); null is a scan that never completed (no document's inline
185
+ // scripts were read and no received script made a call); anything else,
186
+ // such as a DOM scan that answered neither, is a failed scan.
187
+ page_script_involved: Object.freeze({
188
+ at: "observation", key: "page_script_involved",
189
+ valid: (value) => value === null || value === true || value === false, complete: oneOf([true, false]), extractor: "page_script_scan",
190
+ feeds: ["tracking.order", "tracking.tag"], reason: "extractor_failed",
191
+ }),
192
+ measured_seed_seq: Object.freeze({
193
+ at: "observation", key: "measured_seed_seq",
194
+ valid: (value) => value === null || isSeq(value), complete: isSeq, failure: () => null,
195
+ feeds: ["tracking.url"], reason: "seed_hop_not_observed",
196
+ }),
197
+ post_order_seq: Object.freeze({
198
+ at: "observation", key: "post_order_seq",
199
+ valid: (value) => value === null || isSeq(value), complete: isSeq, failure: () => null,
200
+ feeds: ["tracking.url"], reason: "attempt_incomplete",
201
+ }),
202
+ "hop.seq": Object.freeze({
203
+ at: "hop", key: "seq",
204
+ valid: (value, context) => {
205
+ if (!isSeq(value) || context.seqs.has(value)) return false;
206
+ context.seqs.add(value);
207
+ return true;
208
+ },
209
+ complete: () => true, extractor: "hop_equality",
210
+ feeds: ["tracking.url"], reason: "extractor_failed",
211
+ }),
212
+ "hop.initiator": Object.freeze({
213
+ at: "hop", key: "initiator",
214
+ valid: oneOf(["runner", "page"]), complete: () => true, extractor: "hop_equality",
215
+ feeds: ["tracking.url"], reason: "extractor_failed",
216
+ }),
217
+ "hop.kind": Object.freeze({
218
+ at: "hop", key: "kind",
219
+ valid: oneOf(["document", "history"]), complete: () => true, extractor: "hop_equality",
220
+ feeds: ["tracking.url"], reason: "extractor_failed",
221
+ }),
222
+ // false is judged per hop by the URL rule (a gap in the measured sequence).
223
+ "hop.observer_attached": Object.freeze({
224
+ at: "hop", key: "observer_attached",
225
+ valid: oneOf([true, false]), complete: () => true, extractor: "hop_equality",
226
+ feeds: ["tracking.url"], reason: "extractor_failed",
227
+ }),
228
+ "hop.params.*": Object.freeze({
229
+ at: "hop.params",
230
+ valid: oneOf(OUTCOMES), complete: () => true, extractor: "hop_equality",
231
+ feeds: ["tracking.url"], reason: "extractor_failed",
232
+ }),
233
+ "field.source": Object.freeze({
234
+ at: "field", key: "source",
235
+ valid: oneOf(ORDER_SOURCES), complete: () => true, extractor: "response_equality",
236
+ feeds: ["tracking.order"], reason: "extractor_failed",
237
+ }),
238
+ "field.outcome": Object.freeze({
239
+ at: "field", key: "outcome",
240
+ valid: oneOf(OUTCOMES), complete: () => true, extractor: (entry) => (entry.source === "request" ? "request_equality" : "response_equality"),
241
+ feeds: ["tracking.order"], reason: "extractor_failed",
242
+ }),
243
+ "name.source": Object.freeze({
244
+ at: "name", key: "source",
245
+ valid: oneOf(ORDER_SOURCES), complete: () => true, extractor: "request_equality",
246
+ feeds: ["tracking.order", "tracking.tag"], reason: "extractor_failed",
247
+ }),
248
+ // null: the request was not compared (no request body was parsed).
249
+ "name.outcome": Object.freeze({
250
+ at: "name", key: "outcome",
251
+ valid: (value) => value === null || OUTCOMES.includes(value), complete: oneOf(OUTCOMES), extractor: "request_equality",
252
+ feeds: ["tracking.order", "tracking.tag"], reason: "extractor_failed",
253
+ }),
254
+ // Which kind of entry this is: true for a rendered tag's entry, false for a
255
+ // seeded declared name's. An observation in the contract's own shape has
256
+ // none; there the presence of literal_sha256 alone tells them apart.
257
+ "name.rendered": Object.freeze({
258
+ at: "name", key: "rendered", producer: true, optional: true,
259
+ valid: (value, context, entry, present) => (present ? value === true || value === false : context.contractShape),
260
+ complete: () => true, extractor: "tag_dom_read",
261
+ feeds: ["tracking.tag"], reason: "extractor_failed",
262
+ }),
263
+ // Present on a rendered tag's entry only. Its absence is valid only on the
264
+ // entry of a seeded declared name that was never rendered (rendered
265
+ // false), so a rendered tag whose hash is missing is a failed tag read,
266
+ // never a declared name.
267
+ "name.literal_sha256": Object.freeze({
268
+ at: "name", key: "literal_sha256", optional: true,
269
+ valid: (value, context, entry, present) => {
270
+ const rendered = Object.hasOwn(entry, "rendered") ? entry.rendered : context.contractShape ? present : null;
271
+ return present
272
+ ? rendered === true && typeof value === "string" && SHA256_LITERAL.test(value)
273
+ : rendered === false && context.declared.has(entry.tag_or_name);
274
+ },
275
+ complete: () => true, extractor: "tag_dom_read",
276
+ feeds: ["tracking.tag"], reason: "extractor_failed",
277
+ }),
278
+ });
279
+ // The observation-level markers only the producer writes: an observation
280
+ // with none of them is in the contract's own shape.
281
+ const PRODUCER_MARKERS = Object.freeze(Object.keys(OBSERVATION_MARKERS).filter((id) => OBSERVATION_MARKERS[id].producer && OBSERVATION_MARKERS[id].at === "observation"));
282
+ const isContractShape = (observation) => PRODUCER_MARKERS.every((id) => !Object.hasOwn(observation, OBSERVATION_MARKERS[id].key));
283
+ // A rendered tag's entry: marked rendered, or carrying a literal hash. An
284
+ // entry that lost either one stays a tag entry (and fails the tag read).
285
+ const isTagEntry = (entry) => entry.rendered === true || Object.hasOwn(entry, "literal_sha256");
286
+
287
+ const markerReason = (marker, check, value) => {
288
+ if (typeof marker.reason === "function") return marker.reason(value);
289
+ return typeof marker.reason === "string" ? marker.reason : marker.reason[check];
290
+ };
291
+ const markerExtractor = (marker, holder) => (typeof marker.extractor === "function" ? marker.extractor(holder) : marker.extractor ?? null);
292
+
293
+ // Visits every marker location of an observation in schema order:
294
+ // visit(id, marker, holder, key, value, valid). `keys` are the seeded URL
295
+ // names (every hop's params are checked on each) and `declared` the seeded
296
+ // names with no credited field.
297
+ function walkMarkers(observation, { keys, declared }, visit) {
298
+ const context = { seqs: new Set(), declared: new Set(declared), contractShape: isContractShape(observation) };
299
+ const entries = (list) => (Array.isArray(list) ? list.filter(isPlainObject) : []);
300
+ const hops = entries(observation.hops);
301
+ const at = {
302
+ observation: [observation],
303
+ hop: hops,
304
+ field: entries(observation.fields),
305
+ name: entries(observation.names),
306
+ };
307
+ for (const [id, marker] of Object.entries(OBSERVATION_MARKERS)) {
308
+ const locations = marker.at === "hop.params"
309
+ ? hops.filter((hop) => isPlainObject(hop.params)).flatMap((hop) => keys.map((key) => [hop.params, key]))
310
+ : at[marker.at].map((holder) => [holder, marker.key]);
311
+ for (const [holder, key] of locations) {
312
+ const present = Object.hasOwn(holder, key);
313
+ const value = holder[key];
314
+ const valid = present || marker.optional ? marker.valid(value, context, holder, present) : false;
315
+ visit(id, marker, holder, key, value, valid);
316
+ }
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Seed policy
322
+
323
+ export function creditedFieldFor(name) {
324
+ for (const [field, aliases] of Object.entries(CREDITED_FIELD_ALIASES)) {
325
+ if (aliases.includes(name)) return field;
326
+ }
327
+ return null;
328
+ }
329
+
330
+ const isNeverSeeded = (name) => NEVER_SEEDED.includes(name.toLowerCase()) || name.toLowerCase().startsWith("force");
331
+ // A name the persisted projection could change (a query or fragment
332
+ // character, a "%", a "://", a projection marker, or any other text the
333
+ // projection rewrites) is never seeded: no row may depend on a key whose
334
+ // stored form differs from the one it was judged under. The projection of
335
+ // such a name is one too, so the stored name reads the same way again.
336
+ const UNSEEDABLE_TEXT = /[?#%]|:\/\//;
337
+ const changedByProjection = (name) => UNSEEDABLE_TEXT.test(name)
338
+ || name.includes(REDACTED_QUERY)
339
+ || name.includes(TRUNCATED)
340
+ || redactUrlQueriesInText(name) !== name;
341
+ const aliasRank = (field, name) => CREDITED_FIELD_ALIASES[field].indexOf(name);
342
+
343
+ // The URL names a run seeds, by the first rule that applies to each
344
+ // tracking.preserve name: never-seeded names, and names the persisted
345
+ // projection could change (kept as their projection), are excluded; a
346
+ // credited field or alias is seeded in the SDK's preferred form, one name per
347
+ // field; any other name is seeded as a declared name. Seeded keys stop at
348
+ // MAX_SEEDED_KEYS; a name past the cap is listed as overflow.
349
+ export function trackingSeedPlan(preserve = []) {
350
+ const seeded = DEFAULT_SEEDED_URL_NAMES.map((name) => ({ name, field: creditedFieldFor(name), declared: false }));
351
+ const excluded = [];
352
+ const overflow = [];
353
+ const declared = [...new Set((Array.isArray(preserve) ? preserve : [])
354
+ .filter(isNonEmptyString)
355
+ .map((name) => name.trim())
356
+ .map((name) => (changedByProjection(name) ? redactUrlQueriesInText(name) : name)))];
357
+ for (const name of declared) {
358
+ if (isNeverSeeded(name) || changedByProjection(name)) {
359
+ if (!excluded.includes(name)) excluded.push(name);
360
+ continue;
361
+ }
362
+ const field = creditedFieldFor(name);
363
+ const sameField = field ? seeded.find((entry) => entry.field === field) : null;
364
+ if (sameField) {
365
+ // Two names for one credited field: only the SDK's preferred form.
366
+ if (aliasRank(field, name) < aliasRank(field, sameField.name)) sameField.name = name;
367
+ continue;
368
+ }
369
+ if (seeded.some((entry) => entry.name === name)) continue;
370
+ if (seeded.length >= MAX_SEEDED_KEYS) {
371
+ overflow.push({ name, field });
372
+ continue;
373
+ }
374
+ seeded.push({ name, field, declared: true });
375
+ }
376
+ return { seeded, excluded, overflow, preserve: declared };
377
+ }
378
+
379
+ const seedSlug = (name) => name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "param";
380
+
381
+ // The run's seed table: synthetic values cosqa_<name>_<run8>, where run8 is a
382
+ // lowercase hex token generated per run.
383
+ export function createTrackingSeedTable({ spec = null, random = randomBytes } = {}) {
384
+ const preserve = spec?.analytics?.params?.tracking?.preserve;
385
+ const plan = trackingSeedPlan(preserve);
386
+ const token = Buffer.from(random(4)).toString("hex").slice(0, 8).padEnd(8, "0");
387
+ const seeds = Object.fromEntries(plan.seeded.map(({ name }) => [name, `cosqa_${seedSlug(name)}_${token}`]));
388
+ return { plan, seeds };
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Equality (in memory; only the outcome leaves)
393
+
394
+ // A value equals its seed only when it is a string identical to it; any other
395
+ // JSON value (an array, number, object or boolean) differs. A missing value,
396
+ // null or an empty string is absent.
397
+ function outcomeOf(value, expected) {
398
+ if (value === undefined || value === null || value === "") return "absent";
399
+ return typeof value === "string" && value === expected ? "equal" : "differs";
400
+ }
401
+
402
+ function hopParams(url, seeds) {
403
+ const params = new URL(url).searchParams;
404
+ return Object.fromEntries(Object.entries(seeds).map(([name, value]) => [name, outcomeOf(params.has(name) ? params.get(name) : undefined, value)]));
405
+ }
406
+
407
+ // The literal calls a page script uses to set attribution itself.
408
+ export function hasPageScriptCall(text) {
409
+ const source = String(text || "");
410
+ return PAGE_SCRIPT_CALLS.some((call) => source.includes(call));
411
+ }
412
+
413
+ // Read in the page: the declared tags (name and value both non-empty, as the
414
+ // SDK reads them), whether an inline script makes a page-script call, and the
415
+ // SDK loader pin of any script element (package and version only).
416
+ function documentReadScript() {
417
+ return ({ calls, loader }) => {
418
+ const tags = Array.from(document.querySelectorAll('meta[name="os-tracking-tag"], meta[name="data-next-tracking-tag"]'))
419
+ .map((meta) => ({ name: meta.getAttribute("data-tag-name"), value: meta.getAttribute("data-tag-value") }))
420
+ .filter((tag) => tag.name && tag.value);
421
+ const inline = Array.from(document.scripts).some((script) => !script.src && calls.some((call) => (script.textContent || "").includes(call)));
422
+ const pattern = new RegExp(loader, "i");
423
+ const pins = Array.from(document.scripts)
424
+ .map((script) => pattern.exec(script.src || ""))
425
+ .filter(Boolean)
426
+ .map((match) => `campaign-cart@${match[1]}`);
427
+ return { tags, inline, pins };
428
+ };
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Per-run state and the per-attempt observer
433
+
434
+ export function createTrackingRun({ runId, spec = null, hooks = null, random = randomBytes } = {}) {
435
+ const table = createTrackingSeedTable({ spec, random });
436
+ const attempts = new Map();
437
+ return {
438
+ seeds: table.seeds,
439
+ observe(plan) {
440
+ const index = (attempts.get(plan) || 0) + 1;
441
+ attempts.set(plan, index);
442
+ return createTrackingObserver({ table, plan, runId, attemptId: `${plan}:${index}`, hooks });
443
+ },
444
+ // The rows of one plan: computed for the attempt that produced the
445
+ // accepted order, or for the last attempt when none was accepted. The
446
+ // choice is made over every attempt, observed or not, so an attempt with
447
+ // no observation yields no rows (the QC handoff then lists the checks as
448
+ // not captured) rather than an earlier attempt's rows. An attempt that
449
+ // needed recovery on a new page is marked as such; otherwise the
450
+ // observation's own `recovered` marker stands as captured, so a missing
451
+ // or invalid one still reads as not complete.
452
+ rowsFor({ attempts: planAttempts = [], recoveredAttempt = null, measuredAt = new Date().toISOString() } = {}) {
453
+ const all = planAttempts.filter((attempt) => attempt && typeof attempt === "object");
454
+ if (!all.length) return [];
455
+ const accepted = (attempt) => (isPlainObject(attempt[TRACKING_OBSERVATION])
456
+ ? attempt[TRACKING_OBSERVATION].create === "accepted"
457
+ : Number(attempt.order_creates?.accepted_create_responses) > 0);
458
+ const chosen = [...all].reverse().find(accepted) || all.at(-1);
459
+ if (!isPlainObject(chosen[TRACKING_OBSERVATION])) return [];
460
+ const recovered = Boolean(recoveredAttempt) && recoveredAttempt === chosen;
461
+ const observation = recovered ? { ...chosen[TRACKING_OBSERVATION], recovered: true } : chosen[TRACKING_OBSERVATION];
462
+ return trackingQcRows(observation, { measuredAt });
463
+ },
464
+ };
465
+ }
466
+
467
+ export function createTrackingObserver({ table, plan, runId, attemptId, hooks = null }) {
468
+ const { seeds } = table;
469
+ const seededFields = table.plan.seeded.filter((entry) => entry.field);
470
+ const declaredNames = table.plan.seeded.filter((entry) => !entry.field).map((entry) => entry.name);
471
+ const failed = new Set();
472
+ const hops = [];
473
+ // In-flight reads, each registered by observeRead with the extractors it
474
+ // feeds before anything awaits it.
475
+ const pending = new Set();
476
+ let spentMs = 0;
477
+ let nextSeq = 0;
478
+ let runnerPending = false;
479
+ let lastSeededLoad = null;
480
+ let pageHopSeen = false;
481
+ let pageHopCount = 0;
482
+ let measuredSeedSeq = null;
483
+ let gapPending = false;
484
+ let acceptedAt = null;
485
+ let postOrderSeq = null;
486
+ // `frozen`: no document or script read begins once the create request is
487
+ // sent or the attempt is being finalized. `finalized`: the observation has
488
+ // been taken and is never written again; a read settling after it had its
489
+ // extractors failed when it was taken.
490
+ let frozen = false;
491
+ let finalized = false;
492
+ let snapshot = null;
493
+ // Not run until a read completes: tagRead becomes "read" when a document's
494
+ // tag list is read; pageScriptInvolved a boolean when a document's inline
495
+ // scripts are scanned, or true when any scan finds a page-script call.
496
+ let tagRead = "not_run";
497
+ let pageScriptInvolved = null;
498
+ // Every distinct literal each declared tag was rendered with, across the
499
+ // documents read; an empty set is a tag whose literal could not be kept.
500
+ const tagLiterals = new Map();
501
+ const loaderPins = new Set();
502
+ // Every create request of the attempt, kept in memory until finalize and
503
+ // never persisted; and every echo of each response source, in arrival
504
+ // order. No observation replaces an earlier one.
505
+ const requests = [];
506
+ const echoes = { create_response: [], readback: [] };
507
+ // Accepted creates the response listener saw, and how many create bodies
508
+ // each reader (the held tap, the listener) actually read. An accepted create
509
+ // neither reader read is an unread echo, never an absent one.
510
+ const createReads = { accepted: 0, tap: 0, listener: 0 };
511
+
512
+ const fail = (...names) => {
513
+ if (finalized) return;
514
+ for (const name of names) failed.add(name);
515
+ };
516
+ // Each extractor runs inside this wrapper: the test seam is called first,
517
+ // and any exception marks that extractor failed instead of escaping.
518
+ const extract = (name, fn) => {
519
+ try {
520
+ if (typeof hooks?.beforeExtractor === "function") hooks.beforeExtractor(name);
521
+ return { ok: true, value: fn() };
522
+ } catch {
523
+ fail(name);
524
+ return { ok: false };
525
+ }
526
+ };
527
+ const remainingMs = () => Math.max(0, TRACKING_ADDED_BOUND_MS - spentMs);
528
+ // Waits for `promise` at most `limitMs`; a wait the attempt makes is
529
+ // charged to its added-time budget. Never rejects.
530
+ const within = async (promise, limitMs, { charge = true } = {}) => {
531
+ const started = Date.now();
532
+ const TIMED_OUT = Symbol("timed out");
533
+ let timer = null;
534
+ try {
535
+ const outcome = await Promise.race([
536
+ Promise.resolve(promise).then((value) => ({ ok: true, value }), () => ({ ok: false })),
537
+ new Promise((resolve) => { timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, limitMs)); }),
538
+ ]);
539
+ if (outcome === TIMED_OUT) return { status: "timed_out" };
540
+ return outcome.ok ? { status: "ok", value: outcome.value } : { status: "rejected" };
541
+ } finally {
542
+ clearTimeout(timer);
543
+ if (charge) spentMs += Date.now() - started;
544
+ }
545
+ };
546
+ // The one path an asynchronous read feeding a 1.1 row takes (document and
547
+ // script reads, order bodies, the held create body, the tap's CDP setup).
548
+ // The read is registered with the extractors it `feeds` before `start`
549
+ // runs, so nothing awaits it unseen. `record` takes the value of a read
550
+ // that settled before the observation was taken; one that rejects,
551
+ // overruns `limitMs` or throws in `record` fails its extractors, and one
552
+ // still pending when the observation is taken fails them there. A read
553
+ // settling after that changes nothing: the observation already records the
554
+ // failure and is never written again, so no row built from it goes stale.
555
+ // Never rejects.
556
+ const observeRead = (feeds, start, record = null, { limitMs = null, charge = true } = {}) => {
557
+ if (finalized) return Promise.resolve();
558
+ const entry = { feeds, done: null };
559
+ pending.add(entry);
560
+ let read;
561
+ try {
562
+ read = Promise.resolve(start());
563
+ } catch (error) {
564
+ read = Promise.reject(error);
565
+ }
566
+ const outcome = limitMs === null
567
+ ? read.then((value) => ({ status: "ok", value }), () => ({ status: "rejected" }))
568
+ : within(read, limitMs, { charge });
569
+ entry.done = outcome.then((settled) => {
570
+ pending.delete(entry);
571
+ if (finalized) return;
572
+ if (settled.status !== "ok") {
573
+ fail(...feeds);
574
+ return;
575
+ }
576
+ try {
577
+ if (record) record(settled.value);
578
+ } catch {
579
+ fail(...feeds);
580
+ }
581
+ });
582
+ return entry.done;
583
+ };
584
+
585
+ // A body that is not an order object carries no echo.
586
+ const recordEcho = (source, body) => {
587
+ if (!isPlainObject(body)) return;
588
+ extract("response_equality", () => {
589
+ const attribution = isPlainObject(body.attribution) ? body.attribution : null;
590
+ if (attribution) echoes[source].push(Object.fromEntries(seededFields.map(({ name, field }) => [field, outcomeOf(attribution[field], seeds[name])])));
591
+ });
592
+ };
593
+
594
+ // The response listener's in-memory body (null when its read failed). An
595
+ // unread read-back is a failed extractor; an unread create body is one only
596
+ // if the held tap did not read it either (checked at finalize).
597
+ const onOrderResponse = (source, body) => {
598
+ if (body === null || body === undefined) {
599
+ if (source === "readback") fail("response_equality");
600
+ return;
601
+ }
602
+ if (source === "create_response") createReads.listener += 1;
603
+ recordEcho(source, body);
604
+ };
605
+
606
+ // A document read's answer: declared tags as {name, value} strings, the
607
+ // inline page-script scan as a boolean (checked against the
608
+ // page_script_involved marker) and loader pins. A tag list or scan answer
609
+ // of any other shape is that extractor failed, never "nothing rendered" or
610
+ // "no involvement".
611
+ const recordDocument = (value) => {
612
+ if (!isPlainObject(value)) {
613
+ fail("tag_dom_read", "page_script_scan");
614
+ return;
615
+ }
616
+ for (const pin of Array.isArray(value.pins) ? value.pins : []) if (LOADER_PIN_VALUE.test(String(pin))) loaderPins.add(String(pin));
617
+ if (!Array.isArray(value.tags) || !value.tags.every((tag) => isPlainObject(tag) && typeof tag.name === "string" && typeof tag.value === "string")) {
618
+ fail("tag_dom_read");
619
+ } else {
620
+ // The literal is kept exactly as rendered: a non-empty value is a tag
621
+ // even when it is only whitespace (the SDK reads it as given).
622
+ const rendered = value.tags.filter((tag) => isNonEmptyString(tag.name) && tag.value !== "");
623
+ for (const tag of rendered) if (!tagLiterals.has(tag.name)) tagLiterals.set(tag.name, new Set());
624
+ const read = extract("tag_dom_read", () => {
625
+ for (const tag of rendered) tagLiterals.get(tag.name).add(String(tag.value));
626
+ });
627
+ if (read.ok) tagRead = "read";
628
+ }
629
+ if (typeof value.inline !== "boolean") {
630
+ fail("page_script_scan");
631
+ return;
632
+ }
633
+ const scan = extract("page_script_scan", () => value.inline);
634
+ if (scan.ok) pageScriptInvolved = pageScriptInvolved === true || scan.value === true;
635
+ };
636
+
637
+ return {
638
+ // Called by gotoAndSettle before the runner navigates. Before the
639
+ // attempt's first page-initiated hop the URL is seeded; after it, the load
640
+ // is not re-seeded and only breaks continuity.
641
+ runnerUrl(url, addParam) {
642
+ try {
643
+ runnerPending = true;
644
+ if (pageHopSeen) return url;
645
+ lastSeededLoad = { hopSeq: null };
646
+ return Object.entries(seeds).reduce((target, [name, value]) => addParam(target, name, value), url);
647
+ } catch {
648
+ return url;
649
+ }
650
+ },
651
+
652
+ // A listener in qa-browser.mjs could not read its event: a missed hop is
653
+ // a gap in the sequence, a missed response an extractor failure.
654
+ onListenerError(kind) {
655
+ if (kind === "hop") gapPending = true;
656
+ else fail("response_equality");
657
+ },
658
+
659
+ // The hop listener (main frame, framenavigated). `commit` is the
660
+ // browser's own commit signal for this hop, when it gave one:
661
+ // {url, newDocument}, newDocument true for a committed document and false
662
+ // for a same-document navigation (pushState, replaceState, a fragment).
663
+ // A hop is a document hop only when that signal, for exactly this URL,
664
+ // says a new document committed. Every other hop (no signal, a signal
665
+ // for another URL, a signal the page could not give) is a history hop,
666
+ // so it can never be the post-order navigation.
667
+ onFrameNavigated(url, commit = null) {
668
+ try {
669
+ if (!/^https?:\/\//i.test(String(url || ""))) {
670
+ // A main-frame document QA cannot compare (an error page) once
671
+ // the sequence has started is an unobserved step.
672
+ if (hops.length) gapPending = true;
673
+ return;
674
+ }
675
+ const initiator = runnerPending ? "runner" : "page";
676
+ runnerPending = false;
677
+ const kind = isPlainObject(commit) && commit.url === String(url) && commit.newDocument === true ? "document" : "history";
678
+ if (initiator === "page" && !pageHopSeen) {
679
+ pageHopSeen = true;
680
+ measuredSeedSeq = lastSeededLoad ? lastSeededLoad.hopSeq : null;
681
+ }
682
+ if (initiator === "page") pageHopCount += 1;
683
+ // Test seam: a listener attached only after the last pre-page-hop
684
+ // runner load began misses every runner hop before the first page hop.
685
+ if (hooks?.lateHopListener === true && initiator === "runner" && !pageHopSeen) return;
686
+ // Test seam: the observer detached across the n-th page hop.
687
+ if (initiator === "page" && hooks?.detachObserverAtPageHop === pageHopCount) {
688
+ gapPending = true;
689
+ return;
690
+ }
691
+ const params = extract("hop_equality", () => hopParams(url, seeds));
692
+ if (!params.ok) {
693
+ gapPending = true;
694
+ return;
695
+ }
696
+ const seq = nextSeq;
697
+ nextSeq += 1;
698
+ hops.push({ seq, path: persistedPath(url), initiator, kind, observer_attached: !gapPending, params: params.value });
699
+ gapPending = false;
700
+ if (initiator === "runner" && !pageHopSeen && lastSeededLoad) lastSeededLoad.hopSeq = seq;
701
+ if (acceptedAt !== null && postOrderSeq === null && initiator === "page" && kind === "document") postOrderSeq = seq;
702
+ } catch {
703
+ // A hop this listener could not record is a gap in the sequence.
704
+ gapPending = true;
705
+ }
706
+ },
707
+
708
+ // Inside the existing request listener, before summarizeRequestPostData
709
+ // discards the body: an order create request. `postData` is the body or a
710
+ // function that reads it; a read that throws is an extractor failure.
711
+ onCreateRequest(postData) {
712
+ if (finalized) return;
713
+ frozen = true;
714
+ const next = { read: "unreadable", attribution_object: false, sdk_test: false, fields: {}, names: {}, metadata: null };
715
+ requests.push(next);
716
+ extract("request_equality", () => {
717
+ const text = typeof postData === "function" ? postData() : postData;
718
+ let parsed = null;
719
+ try {
720
+ parsed = JSON.parse(String(text ?? ""));
721
+ } catch {
722
+ parsed = null;
723
+ }
724
+ if (!isPlainObject(parsed)) return;
725
+ next.read = "parsed";
726
+ const attribution = isPlainObject(parsed.attribution) ? parsed.attribution : null;
727
+ next.attribution_object = Boolean(attribution);
728
+ const metadata = isPlainObject(attribution?.metadata) ? attribution.metadata : {};
729
+ // Only key presence is read for the SDK's test-order marker.
730
+ next.sdk_test = Boolean(attribution) && (attribution.utm_source === SDK_TEST_UTM_SOURCE || Object.hasOwn(metadata, "test_order"));
731
+ for (const { name, field } of seededFields) next.fields[field] = outcomeOf(attribution?.[field], seeds[name]);
732
+ for (const name of declaredNames) next.names[name] = outcomeOf(Object.hasOwn(metadata, name) ? metadata[name] : undefined, seeds[name]);
733
+ next.metadata = metadata;
734
+ });
735
+ },
736
+
737
+ // Called synchronously when an order create response arrives: the
738
+ // post-order navigation is the first page document hop after an accepted
739
+ // create.
740
+ onCreateResponseStatus(status) {
741
+ if (!(Number(status) >= 200 && Number(status) < 300) || finalized) return;
742
+ createReads.accepted += 1;
743
+ if (acceptedAt === null) acceptedAt = nextSeq;
744
+ },
745
+
746
+ // The response listener's body read of an order response: the create
747
+ // response or the receipt page's own order read-back. The listener starts
748
+ // the read and hands it here before it awaits anything. An unread
749
+ // read-back is a failed extractor; an unread create body is one only if
750
+ // the held tap did not read it either (the reader count at finalize), so
751
+ // the listener's create read feeds the row through that count.
752
+ orderResponseBody(source, start) {
753
+ if (source !== "create_response" && source !== "readback") return Promise.resolve();
754
+ return observeRead(source === "readback" ? ["response_equality"] : [], start, (body) => onOrderResponse(source, body), { charge: false });
755
+ },
756
+
757
+ // The create response body, read while the response is held. Resolves
758
+ // within the attempt's remaining added-time budget and never rejects; a
759
+ // read that fails, returns nothing or overruns the budget marks
760
+ // response_equality failed.
761
+ tapCreateResponse(readBody) {
762
+ return observeRead(["response_equality"], readBody, (body) => {
763
+ if (body === null || body === undefined) {
764
+ fail("response_equality");
765
+ return;
766
+ }
767
+ createReads.tap += 1;
768
+ recordEcho("create_response", body);
769
+ }, { limitMs: remainingMs() });
770
+ },
771
+
772
+ // The CDP setup the create-body tap needs (session and Fetch.enable). It
773
+ // feeds no extractor itself: a tap that never attached reads nothing, and
774
+ // an accepted create no reader read is failed at finalize.
775
+ attachTap(start) {
776
+ return observeRead([], start, null, { charge: false });
777
+ },
778
+
779
+ // Script responses the page already received (no new request): a
780
+ // page-script call in one counts as involvement. A body that cannot be
781
+ // read, or is over 1 MiB, is a failed scan.
782
+ onScriptResponse(response) {
783
+ try {
784
+ const pin = loaderPinOf(response.url());
785
+ if (pin) loaderPins.add(pin);
786
+ } catch {
787
+ // The pin is read again from the document's script elements.
788
+ }
789
+ if (frozen) return Promise.resolve();
790
+ return observeRead(["page_script_scan"], () => {
791
+ const declared = Number(response.headers()?.["content-length"]);
792
+ if (Number.isFinite(declared) && declared > SCRIPT_SCAN_MAX_BYTES) throw new RangeError("script over the scan limit");
793
+ return response.body();
794
+ }, (bytes) => {
795
+ if (!bytes || typeof bytes.length !== "number" || bytes.length > SCRIPT_SCAN_MAX_BYTES) {
796
+ fail("page_script_scan");
797
+ return;
798
+ }
799
+ const scan = extract("page_script_scan", () => hasPageScriptCall(bytes.toString("utf8")));
800
+ if (scan.ok && scan.value) pageScriptInvolved = true;
801
+ }, { charge: false });
802
+ },
803
+
804
+ // The live DOM of an observed document: declared tags, inline page-script
805
+ // calls and the loader pin. A read the runner awaits is held to the
806
+ // attempt's remaining added-time budget; one nothing awaits (a document's
807
+ // domcontentloaded) to DOCUMENT_READ_TIMEOUT_MS. A read that rejects,
808
+ // times out or returns no object marks the tag read and the page-script
809
+ // scan failed. No read begins after the create request (a document
810
+ // rendered after it cannot have shaped it); one begun before it is
811
+ // recorded if it lands before the observation is taken.
812
+ readDocument(page, { awaited = true } = {}) {
813
+ if (frozen) return Promise.resolve();
814
+ return observeRead(
815
+ ["tag_dom_read", "page_script_scan"],
816
+ () => page.evaluate(documentReadScript(), { calls: PAGE_SCRIPT_CALLS, loader: LOADER_PIN_PATTERN.source }),
817
+ recordDocument,
818
+ { limitMs: awaited ? remainingMs() : DOCUMENT_READ_TIMEOUT_MS, charge: awaited },
819
+ );
820
+ },
821
+
822
+ // The attempt's observation: synthetic seeds, hop and order equality
823
+ // outcomes, declared tag names and literal hashes. `create` comes from
824
+ // summarizeOrderCreateActivity over the whole event log. Reads still in
825
+ // flight get what is left of the added-time budget; every one still
826
+ // pending after it is a failed extractor, as is an accepted create whose
827
+ // body no reader read. Its markers are checked against
828
+ // OBSERVATION_MARKERS before it is returned, and it is never written
829
+ // again.
830
+ async finalize({ createActivity = null } = {}) {
831
+ if (snapshot) return snapshot;
832
+ frozen = true;
833
+ if (pending.size) await within(Promise.allSettled([...pending].map((entry) => entry.done)), remainingMs());
834
+ for (const entry of pending) fail(...entry.feeds);
835
+ if (createReads.accepted > Math.max(createReads.tap, createReads.listener)) fail("response_equality");
836
+ finalized = true;
837
+ const create = createOutcome(createActivity);
838
+ const parsed = requests.filter((entry) => entry.read === "parsed");
839
+ const requestRead = !requests.length ? "none" : requests.some((entry) => entry.read !== "parsed") ? "unreadable" : "parsed";
840
+ const fields = [];
841
+ for (const { field } of seededFields) {
842
+ for (const entry of parsed) fields.push({ field, source: "request", outcome: entry.fields[field] });
843
+ for (const source of ["create_response", "readback"]) {
844
+ for (const echo of echoes[source]) fields.push({ field, source, outcome: echo[field] });
845
+ }
846
+ }
847
+ const names = [];
848
+ for (const name of declaredNames) {
849
+ if (!parsed.length) names.push({ tag_or_name: name, source: "request", outcome: null, rendered: false });
850
+ for (const entry of parsed) names.push({ tag_or_name: name, source: "request", outcome: entry.names[name], rendered: false });
851
+ }
852
+ for (const [tag, literals] of tagLiterals) {
853
+ const tagName = persistedTagName(tag);
854
+ if (!literals.size) names.push({ tag_or_name: tagName, source: "request", outcome: null, literal_sha256: null, rendered: true });
855
+ for (const literal of literals) {
856
+ const literalSha = sha256(literal);
857
+ if (!parsed.length) names.push({ tag_or_name: tagName, source: "request", outcome: null, literal_sha256: literalSha, rendered: true });
858
+ for (const entry of parsed) {
859
+ const metadata = entry.metadata || {};
860
+ names.push({ tag_or_name: tagName, source: "request", outcome: outcomeOf(Object.hasOwn(metadata, tag) ? metadata[tag] : undefined, literal), literal_sha256: literalSha, rendered: true });
861
+ }
862
+ }
863
+ }
864
+ for (const entry of requests) entry.metadata = null;
865
+ const raw = {
866
+ plan,
867
+ run_id: runId,
868
+ attempt_id: attemptId,
869
+ seeds: { ...seeds },
870
+ preserve: [...table.plan.preserve],
871
+ hops: hops.map((hop) => ({ ...hop, params: { ...hop.params } })),
872
+ measured_seed_seq: pageHopSeen ? measuredSeedSeq : (lastSeededLoad ? lastSeededLoad.hopSeq : null),
873
+ post_order_seq: postOrderSeq,
874
+ create,
875
+ request_read: requestRead,
876
+ attribution_object: parsed.length > 0 && parsed.every((entry) => entry.attribution_object),
877
+ sdk_test_attribution: parsed.length ? parsed.some((entry) => entry.sdk_test) : null,
878
+ page_script_involved: pageScriptInvolved,
879
+ fields,
880
+ names,
881
+ tag_read: failed.has("tag_dom_read") ? "failed" : tagRead,
882
+ extractor_failed: EXTRACTORS.filter((name) => failed.has(name)),
883
+ loader_pins: [...loaderPins].sort(),
884
+ recovered: false,
885
+ };
886
+ // Test seam: the raw observation as capture produced it, before its
887
+ // markers are checked.
888
+ if (typeof hooks?.rawObservation === "function") hooks.rawObservation(raw);
889
+ snapshot = captureMarkers(raw, { keys: Object.keys(seeds), declared: declaredNames });
890
+ return snapshot;
891
+ },
892
+ };
893
+ }
894
+
895
+ // Capture's check of the finalized observation against OBSERVATION_MARKERS:
896
+ // an invalid marker is written as its failure value or fails its extractor,
897
+ // so a stored observation never holds a value outside the schema that a
898
+ // reader could take for a success. tag_read, page_script_involved and
899
+ // extractor_failed are kept in step: a tag read or page-script scan that is
900
+ // not complete is its extractor failed, and a failed tag read is never "read".
901
+ function captureMarkers(raw, scope) {
902
+ const failed = new Set();
903
+ walkMarkers(raw, scope, (id, marker, holder, key, value, valid) => {
904
+ if (valid) return;
905
+ if (marker.failure) holder[key] = marker.failure();
906
+ const extractor = markerExtractor(marker, holder);
907
+ if (extractor) failed.add(extractor);
908
+ });
909
+ for (const name of raw.extractor_failed) failed.add(name);
910
+ if (raw.tag_read !== "read") failed.add("tag_dom_read");
911
+ if (raw.page_script_involved === null) failed.add("page_script_scan");
912
+ raw.extractor_failed = EXTRACTORS.filter((name) => failed.has(name));
913
+ if (failed.has("tag_dom_read") && raw.tag_read === "read") raw.tag_read = "failed";
914
+ return raw;
915
+ }
916
+
917
+ function createOutcome(activity) {
918
+ if (!isPlainObject(activity)) return "none";
919
+ if (Number(activity.accepted_create_responses) > 0) return "accepted";
920
+ if (Number(activity.rejected_create_responses) > 0) return "rejected";
921
+ if (Number(activity.failed_create_requests) > 0 || Number(activity.create_requests) > 0) return "failed";
922
+ return "none";
923
+ }
924
+
925
+ // ---------------------------------------------------------------------------
926
+ // Rows
927
+
928
+ // Every row of an attempt, each built from rederiveQcResult so the stored row
929
+ // and a later read agree by construction.
930
+ export function trackingQcRows(observation, { measuredAt = new Date().toISOString() } = {}) {
931
+ const tags = renderedTags(observation);
932
+ const parts = [
933
+ { check: "tracking.url" },
934
+ { check: "tracking.order" },
935
+ ...tags.map((tag) => ({ check: "tracking.tag", tag })),
936
+ ...(!tags.length && tagReadFailed(observation) ? [{ check: "tracking.tag", tag: null }] : []),
937
+ ];
938
+ const rows = [];
939
+ for (const part of parts) {
940
+ const rowObservation = { ...observation, check: part.check };
941
+ if (part.check === "tracking.tag") rowObservation.tag = part.tag;
942
+ const row = buildRow(rowObservation, measuredAt);
943
+ if (row) rows.push(row);
944
+ }
945
+ return rows;
946
+ }
947
+
948
+ // The run-scope rows of a QA run with no browser test order requested.
949
+ export function trackingRunScopeRows({ runId = null, measuredAt = new Date().toISOString() } = {}) {
950
+ return TRACKING_CHECKS.map((check) => buildRow({ scope: "run", check, run_id: runId, reason_code: "test_order_not_requested" }, measuredAt)).filter(Boolean);
951
+ }
952
+
953
+ export function trackingQaAssertion(row) {
954
+ return toQaAssertion(row, { family: FAMILY });
955
+ }
956
+
957
+ // Whether the observation's tag read is anything but complete (the tag-less
958
+ // tag row then reports it).
959
+ function tagReadFailed(observation) {
960
+ try {
961
+ return Boolean(readObservation(observation)?.failed.includes("tag_dom_read"));
962
+ } catch {
963
+ return false;
964
+ }
965
+ }
966
+
967
+ function buildRow(observation, measuredAt) {
968
+ const derived = rederiveQcResult(observation);
969
+ if (!derived) return null;
970
+ return buildQcResult({
971
+ check: derived.check,
972
+ leg: "qa",
973
+ subject: derived.subject,
974
+ result: derived.result,
975
+ reason_code: derived.reason_code,
976
+ state: derived.state,
977
+ observation,
978
+ members: derived.members,
979
+ accept_eligible: derived.accept_eligible,
980
+ coverage: derived.coverage,
981
+ measured_at: measuredAt,
982
+ });
983
+ }
984
+
985
+ // ---------------------------------------------------------------------------
986
+ // Re-derivation
987
+
988
+ // Re-derives one row from its stored observation, or returns null when the
989
+ // observation is not one this rule can read (the reader then reports
990
+ // evidence_not_reproducible).
991
+ export function rederiveQcResult(observation) {
992
+ try {
993
+ return derive(observation);
994
+ } catch {
995
+ return null;
996
+ }
997
+ }
998
+
999
+ function derive(observation) {
1000
+ if (!isPlainObject(observation) || !TRACKING_CHECKS.includes(observation.check)) return null;
1001
+ const { check } = observation;
1002
+ if (observation.scope === "run") {
1003
+ if (observation.reason_code !== "test_order_not_requested") return null;
1004
+ return {
1005
+ check,
1006
+ subject: { check, page: "run", key: ROW_KEY[check] },
1007
+ result: "excluded",
1008
+ reason_code: "test_order_not_requested",
1009
+ members: [],
1010
+ accept_eligible: false,
1011
+ // Nothing was loaded, so no loader pin could be read.
1012
+ coverage: { observed: 0, expected: null, limits: [], loader_pins: null },
1013
+ state: { scope: "run", run_id: observation.run_id ?? null, reason_code: "test_order_not_requested" },
1014
+ };
1015
+ }
1016
+ const read = readObservation(observation);
1017
+ if (!read) return null;
1018
+ if (check === "tracking.url") return urlRow(read);
1019
+ if (check === "tracking.order") return orderRow(read);
1020
+ return tagRow(read, observation.tag);
1021
+ }
1022
+
1023
+ // The producer markers of an observation in the contract's own shape (none
1024
+ // of them stored), as its rules read them.
1025
+ const CONTRACT_SHAPE = Object.freeze({
1026
+ extractor_failed: () => [],
1027
+ tag_read: () => "read",
1028
+ request_read: (observation) => (observation.create === "accepted" || observation.create === "rejected" || observation.create === "failed" ? "parsed" : "none"),
1029
+ sdk_test_attribution: () => false,
1030
+ recovered: () => false,
1031
+ });
1032
+
1033
+ // Re-derivation's check of a stored observation against OBSERVATION_MARKERS.
1034
+ // Returns the observation-level values the rules read (an invalid one as its
1035
+ // failure value), the failed extractors, and per row the first reason a
1036
+ // marker bars its pass (`barred`). An observation with none of the producer
1037
+ // markers is the contract's own shape: its rules read the contract's fields
1038
+ // as they stand, but nothing records that its reads completed, so no row of
1039
+ // it can pass.
1040
+ function readMarkers(observation, scope) {
1041
+ const contractShape = isContractShape(observation);
1042
+ const values = {};
1043
+ const failed = new Set();
1044
+ const barred = new Map();
1045
+ const bar = (checks, reasonOf) => {
1046
+ for (const check of checks) if (!barred.has(check)) barred.set(check, reasonOf(check));
1047
+ };
1048
+ walkMarkers(observation, scope, (id, marker, holder, key, value, valid) => {
1049
+ if (contractShape && marker.producer && marker.at === "observation") {
1050
+ values[key] = CONTRACT_SHAPE[key](observation);
1051
+ bar(marker.feeds, (check) => markerReason(marker, check, null));
1052
+ return;
1053
+ }
1054
+ const read = valid || !marker.failure ? value : marker.failure();
1055
+ if (marker.at === "observation") values[key] = read;
1056
+ if (!valid) {
1057
+ const extractor = markerExtractor(marker, holder);
1058
+ if (extractor) failed.add(extractor);
1059
+ }
1060
+ if (!valid) bar(marker.feeds, (check) => markerReason(marker, check, read));
1061
+ else if (!marker.complete(read)) bar(marker.bars ? marker.bars(read) : marker.feeds, (check) => markerReason(marker, check, read));
1062
+ });
1063
+ for (const name of Array.isArray(values.extractor_failed) ? values.extractor_failed : EXTRACTORS) failed.add(name);
1064
+ if (values.tag_read !== "read") failed.add("tag_dom_read");
1065
+ if (values.page_script_involved === null) failed.add("page_script_scan");
1066
+ for (const name of EXTRACTORS) if (failed.has(name)) bar(EXTRACTOR_ROWS[name], () => "extractor_failed");
1067
+ return { values, failed, barred };
1068
+ }
1069
+
1070
+ // The observation, checked against the vocabulary and against the seed plan
1071
+ // its own preserve list implies (an observation outside them is not
1072
+ // re-derivable); its markers are checked by readMarkers.
1073
+ function readObservation(observation) {
1074
+ const {
1075
+ plan, run_id: runId, attempt_id: attemptId, seeds, hops, fields, names,
1076
+ } = observation;
1077
+ if (!isNonEmptyString(plan) || !isNonEmptyString(runId) || !isNonEmptyString(attemptId)) return null;
1078
+ if (!isPlainObject(seeds) || !Array.isArray(hops) || !Array.isArray(fields) || !Array.isArray(names)) return null;
1079
+ const preserve = observation.preserve ?? [];
1080
+ if (!Array.isArray(preserve) || !preserve.every((name) => typeof name === "string")) return null;
1081
+ const seedPlan = trackingSeedPlan(preserve);
1082
+ const keys = seedPlan.seeded.map((entry) => entry.name);
1083
+ if (!sameSet(Object.keys(seeds), keys) || !keys.every((key) => isNonEmptyString(seeds[key]))) return null;
1084
+ for (const hop of hops) {
1085
+ if (!isPlainObject(hop) || typeof hop.path !== "string" || !isPlainObject(hop.params)) return null;
1086
+ if (!Object.keys(hop.params).every((key) => keys.includes(key))) return null;
1087
+ }
1088
+ if (!fields.every((entry) => isPlainObject(entry) && isNonEmptyString(entry.field))) return null;
1089
+ if (!names.every((entry) => isPlainObject(entry) && isNonEmptyString(entry.tag_or_name))) return null;
1090
+ const loaderPins = observation.loader_pins ?? [];
1091
+ if (!Array.isArray(loaderPins) || !loaderPins.every((pin) => typeof pin === "string" && LOADER_PIN_VALUE.test(pin))) return null;
1092
+ const declared = seedPlan.seeded.filter((entry) => !entry.field).map((entry) => entry.name);
1093
+ const { values, failed, barred } = readMarkers(observation, { keys, declared });
1094
+ return {
1095
+ observation,
1096
+ plan,
1097
+ seedPlan,
1098
+ keys,
1099
+ hops: [...hops].sort((a, b) => a.seq - b.seq),
1100
+ measuredSeedSeq: values.measured_seed_seq,
1101
+ postOrderSeq: values.post_order_seq,
1102
+ create: values.create,
1103
+ requestRead: values.request_read,
1104
+ attributionObject: values.attribution_object === true,
1105
+ sdkTest: values.sdk_test_attribution !== false,
1106
+ pageScript: values.page_script_involved === true,
1107
+ recovered: values.recovered !== false,
1108
+ failed: EXTRACTORS.filter((name) => failed.has(name)),
1109
+ barred,
1110
+ fields,
1111
+ names: names.map((entry) => (entry.outcome === null || OUTCOMES.includes(entry.outcome) ? entry : { ...entry, outcome: null })),
1112
+ loaderPins: [...new Set(loaderPins)].sort(),
1113
+ };
1114
+ }
1115
+
1116
+ // A row whose members would read pass while a marker it depends on is not
1117
+ // complete reads unexercised with that marker's reason instead: every pass
1118
+ // member takes it.
1119
+ function barPass(read, check, members) {
1120
+ const reason = read.barred.get(check);
1121
+ if (!reason || aggregateQcResults(members).result !== "pass") return members;
1122
+ return members.map((entry) => (entry.result === "pass" ? member(entry.key, "unexercised", reason) : entry));
1123
+ }
1124
+
1125
+ const sameSet = (a, b) => a.length === b.length && new Set(a).size === a.length && b.every((item) => a.includes(item));
1126
+
1127
+ // What the accept state binds: run and attempt, the expected seeds, the full
1128
+ // hop equality matrix, every order source outcome, the create outcome and
1129
+ // page-script involvement, plus the row's own result.
1130
+ function rowState(read, { reasonCode, members, extra = {} }) {
1131
+ const { observation } = read;
1132
+ return {
1133
+ run_id: observation.run_id,
1134
+ attempt_id: observation.attempt_id,
1135
+ reason_code: reasonCode,
1136
+ members: members.map(({ key, result, reason_code: reason }) => ({ key, result, reason_code: reason ?? null })),
1137
+ seeds: observation.seeds,
1138
+ hops: read.hops.map(({ seq, path, initiator, kind, observer_attached: attached, params }) => ({ seq, path, initiator, kind, observer_attached: attached, params })),
1139
+ fields: read.fields.map(({ field, source, outcome }) => ({ field, source, outcome })),
1140
+ names: observation.names.map(({ tag_or_name: name, source, outcome }) => ({ tag_or_name: name, source: source ?? null, outcome: outcome ?? null })),
1141
+ create: observation.create ?? null,
1142
+ page_script_involved: observation.page_script_involved ?? null,
1143
+ ...extra,
1144
+ };
1145
+ }
1146
+
1147
+ const member = (key, result, reasonCode = null) => ({ key, result, reason_code: result === "pass" ? null : reasonCode });
1148
+ const unexercisedReasons = (members) => [...new Set(members.filter((entry) => entry.result === "unexercised").map((entry) => entry.reason_code))];
1149
+
1150
+ // The 1.0 coverage shape: `expected` counts the members in scope (every one
1151
+ // not excluded), `observed` those judged (in scope and not unexercised). The
1152
+ // row's own limits and extra fields follow, then the campaign's SDK loader
1153
+ // pins as observed during the attempt.
1154
+ function rowCoverage(read, members, extra) {
1155
+ const inScope = members.filter((entry) => entry.result !== "excluded");
1156
+ return {
1157
+ observed: inScope.filter((entry) => entry.result !== "unexercised").length,
1158
+ expected: inScope.length,
1159
+ ...extra,
1160
+ loader_pins: read.loaderPins,
1161
+ };
1162
+ }
1163
+
1164
+ function finishRow(read, check, key, members, coverage) {
1165
+ const aggregate = aggregateQcResults(members);
1166
+ return {
1167
+ check,
1168
+ subject: { check, page: read.plan, key },
1169
+ result: aggregate.result,
1170
+ reason_code: aggregate.reason_code,
1171
+ members,
1172
+ accept_eligible: aggregate.accept_eligible,
1173
+ coverage: rowCoverage(read, members, coverage),
1174
+ state: rowState(read, { reasonCode: aggregate.reason_code, members }),
1175
+ };
1176
+ }
1177
+
1178
+ // ---- URL preservation
1179
+
1180
+ // The continuously observed sequence from the measured seed hop: it stops at
1181
+ // the post-order navigation, at a runner hop after the first page hop, at a
1182
+ // gap (a missing seq or a hop observed after the observer was detached), or
1183
+ // at the last hop. A gap at or before the measured seed hop (the seed hop or
1184
+ // an earlier hop recorded with observer_attached false, or a seq missing
1185
+ // below it) leaves the seed hop itself unproven: the whole sequence reads as
1186
+ // an observation gap.
1187
+ function observedSequence(read) {
1188
+ const bySeq = new Map(read.hops.map((hop) => [hop.seq, hop]));
1189
+ const seedHop = read.measuredSeedSeq === null ? null : bySeq.get(read.measuredSeedSeq) || null;
1190
+ if (!seedHop) return { seedHop: null, sequence: [], reached: false, stop: "seed_hop_not_observed" };
1191
+ for (let seq = 0; seq <= seedHop.seq; seq += 1) {
1192
+ if (bySeq.get(seq)?.observer_attached !== true) return { seedHop, sequence: [seedHop], reached: false, stop: "observation_gap", seedGap: true };
1193
+ }
1194
+ const postHop = read.create === "accepted" && read.postOrderSeq !== null ? bySeq.get(read.postOrderSeq) : null;
1195
+ const postOrderSeq = postHop && postHop.initiator === "page" && postHop.kind === "document" && postHop.seq > seedHop.seq ? postHop.seq : null;
1196
+ const sequence = [seedHop];
1197
+ let previous = seedHop;
1198
+ let stop = null;
1199
+ while (previous.seq !== postOrderSeq) {
1200
+ const next = bySeq.get(previous.seq + 1);
1201
+ if (!next) {
1202
+ stop = read.hops.some((hop) => hop.seq > previous.seq) ? "observation_gap" : "end";
1203
+ break;
1204
+ }
1205
+ if (next.observer_attached !== true) {
1206
+ stop = "observation_gap";
1207
+ break;
1208
+ }
1209
+ if (next.initiator === "runner") {
1210
+ stop = "runner_reload_in_sequence";
1211
+ break;
1212
+ }
1213
+ sequence.push(next);
1214
+ previous = next;
1215
+ }
1216
+ return { seedHop, sequence, reached: postOrderSeq !== null && previous.seq === postOrderSeq, stop };
1217
+ }
1218
+
1219
+ // One seeded key along the observed sequence. A failing hop is named only for
1220
+ // a drop between two observed page hops; a loss next to the runner's own seed
1221
+ // load is never blamed on a hop.
1222
+ function urlKeyResult(key, observed) {
1223
+ const { seedHop, sequence, reached, stop } = observed;
1224
+ if (!seedHop) return member(key, "unexercised", "seed_hop_not_observed");
1225
+ if (observed.seedGap) return member(key, "unexercised", "observation_gap");
1226
+ if (seedHop.params[key] !== "equal") return member(key, "unexercised", "seed_hop_not_observed");
1227
+ let warning = null;
1228
+ let review = null;
1229
+ let unnamedLoss = false;
1230
+ for (let index = 1; index < sequence.length; index += 1) {
1231
+ const from = sequence[index - 1];
1232
+ const to = sequence[index];
1233
+ const before = from.params[key];
1234
+ const after = to.params[key];
1235
+ if (after === "equal") continue;
1236
+ if (to.kind === "document") {
1237
+ if (after === "differs") warning = warning || { reason: "url_param_changed" };
1238
+ else if (before === "equal" && from.initiator === "page") warning = warning || { reason: "url_param_dropped", failing_hop: { from: from.path, to: to.path } };
1239
+ else if (before === "equal") unnamedLoss = true;
1240
+ } else if (before === "equal" || before !== after) {
1241
+ review = review || { reason: "history_rewrite" };
1242
+ }
1243
+ }
1244
+ if (warning) {
1245
+ const result = member(key, "warning", warning.reason);
1246
+ return warning.failing_hop ? { ...result, failing_hop: warning.failing_hop } : result;
1247
+ }
1248
+ if (review) return member(key, "review", review.reason);
1249
+ if (unnamedLoss) return member(key, "unexercised", "no_page_hop_after_seed");
1250
+ if (reached) return member(key, "pass");
1251
+ if (stop === "observation_gap" || stop === "runner_reload_in_sequence") return member(key, "unexercised", stop);
1252
+ return member(key, "unexercised", sequence.length > 1 ? "attempt_incomplete" : "no_page_hop_after_seed");
1253
+ }
1254
+
1255
+ function urlRow(read) {
1256
+ const observed = observedSequence(read);
1257
+ const forced = read.failed.includes("hop_equality") ? "extractor_failed" : read.recovered ? "attempt_recovered_on_new_page" : null;
1258
+ const members = barPass(read, "tracking.url", [
1259
+ ...read.keys.map((key) => (forced ? member(key, "unexercised", forced) : urlKeyResult(key, observed))),
1260
+ ...read.seedPlan.excluded.map((name) => member(name, "excluded", "not_seeded_by_policy")),
1261
+ ...read.seedPlan.overflow.map(({ name }) => member(name, "unexercised", "seed_allowlist_overflow")),
1262
+ ]);
1263
+ const lastObserved = observed.sequence.length ? observed.sequence.at(-1).path : null;
1264
+ return finishRow(read, "tracking.url", "url", members, { last_observed: lastObserved, limits: unexercisedReasons(members) });
1265
+ }
1266
+
1267
+ // ---- Order attribution
1268
+
1269
+ // Why no seeded field can be judged, if any: the first that applies.
1270
+ function orderBlocker(read) {
1271
+ if (read.failed.includes("request_equality") || read.failed.includes("response_equality") || read.failed.includes("page_script_scan")) return "extractor_failed";
1272
+ if (read.recovered) return "attempt_recovered_on_new_page";
1273
+ if (read.create !== "accepted" || read.requestRead === "none") return "no_accepted_order";
1274
+ if (read.requestRead === "unreadable") return "request_body_unreadable";
1275
+ if (!read.attributionObject) return "attribution_not_sent";
1276
+ return null;
1277
+ }
1278
+
1279
+ // Every observation of every source counts; none replaces another. A pass
1280
+ // needs at least one request observation, every request observation equal,
1281
+ // and no observation of any source that differs.
1282
+ function sourceOutcomes(entries) {
1283
+ const bySource = Object.fromEntries(ORDER_SOURCES.map((source) => [source, []]));
1284
+ for (const entry of entries) bySource[entry.source].push(entry.outcome);
1285
+ return { bySource, all: entries.map((entry) => entry.outcome) };
1286
+ }
1287
+
1288
+ function fieldResult(read, field) {
1289
+ const { bySource, all } = sourceOutcomes(read.fields.filter((entry) => entry.field === field));
1290
+ if (!bySource.request.length) throw new TypeError(`no request outcome for ${field}`);
1291
+ if (all.includes("differs")) return read.pageScript ? ["review", "page_script_mapping"] : ["warning", "order_attribution_differs"];
1292
+ if (bySource.request.includes("absent")) return read.pageScript ? ["review", "page_script_mapping"] : ["warning", "order_attribution_missing"];
1293
+ if (read.pageScript && all.includes("absent")) return ["review", "page_script_mapping"];
1294
+ return ["pass", null];
1295
+ }
1296
+
1297
+ // A declared name the SDK credits to no field: out of scope by the SDK's map,
1298
+ // unless a rendered tag shares its name or the request metadata carries it.
1299
+ function declaredNameResult(read, name) {
1300
+ if (read.names.some((entry) => entry.tag_or_name === name && isTagEntry(entry))) return ["review", "declared_tag_same_name"];
1301
+ const carried = read.names.filter((entry) => entry.tag_or_name === name && entry.source === "request" && !isTagEntry(entry));
1302
+ if (carried.some((entry) => entry.outcome === "equal" || entry.outcome === "differs")) return ["review", "page_script_mapping"];
1303
+ return ["excluded", "no_credited_field"];
1304
+ }
1305
+
1306
+ function orderRow(read) {
1307
+ const blocker = orderBlocker(read);
1308
+ let members = [];
1309
+ const seen = new Set();
1310
+ const add = (key, result, reasonCode) => {
1311
+ if (seen.has(key)) return;
1312
+ seen.add(key);
1313
+ members.push(member(key, result, reasonCode));
1314
+ };
1315
+ for (const { field } of read.seedPlan.seeded) {
1316
+ if (!field) continue;
1317
+ if (blocker) add(field, "unexercised", blocker);
1318
+ else if (read.sdkTest && UTM_FIELDS.includes(field)) add(field, "unexercised", "sdk_test_attribution");
1319
+ else add(field, ...fieldResult(read, field));
1320
+ }
1321
+ const seededFields = new Set(read.seedPlan.seeded.map((entry) => entry.field).filter(Boolean));
1322
+ for (const field of NOT_SEEDED_BY_DEFAULT) if (!seededFields.has(field)) add(field, "excluded", "not_seeded_by_policy");
1323
+ for (const name of read.seedPlan.excluded) add(creditedFieldFor(name) || name, "excluded", "not_seeded_by_policy");
1324
+ for (const { name } of read.seedPlan.seeded) if (!creditedFieldFor(name)) add(name, ...declaredNameResult(read, name));
1325
+ for (const { name, field } of read.seedPlan.overflow) {
1326
+ if (field) add(field, "unexercised", "seed_allowlist_overflow");
1327
+ else add(name, ...declaredNameResult(read, name));
1328
+ }
1329
+ members = barPass(read, "tracking.order", members);
1330
+ const sourcesObserved = ORDER_SOURCES.filter((source) => read.fields.some((entry) => entry.source === source));
1331
+ const echoLimits = read.create === "accepted" && read.requestRead === "parsed"
1332
+ ? ["create_response", "readback"].filter((source) => !sourcesObserved.includes(source)).map((source) => `${source}: absent`)
1333
+ : [];
1334
+ return finishRow(read, "tracking.order", "order", members, { sources_observed: sourcesObserved, limits: [...echoLimits, ...unexercisedReasons(members)] });
1335
+ }
1336
+
1337
+ // ---- Declared tags
1338
+
1339
+ function renderedTags(observation) {
1340
+ const names = Array.isArray(observation?.names) ? observation.names : [];
1341
+ return [...new Set(names.filter((entry) => isPlainObject(entry) && isTagEntry(entry) && isNonEmptyString(entry.tag_or_name)).map((entry) => entry.tag_or_name))];
1342
+ }
1343
+
1344
+ // A tag row judges the request: a pass needs at least one request
1345
+ // observation of metadata[X], every one equal to its literal, and no
1346
+ // observation of any source that differs. A tag rendered with more than one
1347
+ // literal keeps an entry per literal, so a request can equal only one of them.
1348
+ function tagRow(read, tag) {
1349
+ const check = "tracking.tag";
1350
+ const entries = tag === null || tag === undefined
1351
+ ? []
1352
+ : read.names.filter((candidate) => candidate.tag_or_name === tag && isTagEntry(candidate));
1353
+ if (tag !== null && tag !== undefined && !entries.length) return null;
1354
+ if (!entries.length && !read.failed.includes("tag_dom_read")) return null;
1355
+ const literalShas = [...new Set(entries.map((entry) => entry.literal_sha256))].sort();
1356
+ // A literal not kept as a sha256, or an entry the request was not compared
1357
+ // with, is an incomplete tag read.
1358
+ const incomplete = entries.some((entry) => typeof entry.literal_sha256 !== "string" || !/^sha256:[a-f0-9]{64}$/.test(entry.literal_sha256) || entry.outcome === null);
1359
+ let result;
1360
+ if (read.failed.includes("tag_dom_read") || read.failed.includes("request_equality") || read.failed.includes("page_script_scan")) result = ["unexercised", "extractor_failed"];
1361
+ else if (read.create !== "accepted" || read.requestRead === "none") result = ["unexercised", "no_accepted_order"];
1362
+ else if (read.requestRead === "unreadable") result = ["unexercised", "request_body_unreadable"];
1363
+ else if (incomplete) result = ["unexercised", "extractor_failed"];
1364
+ else {
1365
+ const { bySource, all } = sourceOutcomes(entries);
1366
+ if (!bySource.request.length) return null;
1367
+ if (all.includes("differs")) result = read.pageScript ? ["review", "page_script_mapping"] : ["warning", "tag_value_differs"];
1368
+ else if (bySource.request.includes("absent")) result = read.pageScript ? ["review", "page_script_mapping"] : ["warning", "tag_missing"];
1369
+ else if (read.pageScript && all.includes("absent")) result = ["review", "page_script_mapping"];
1370
+ else result = ["pass", null];
1371
+ }
1372
+ if (result[0] === "pass" && read.barred.has(check)) result = ["unexercised", read.barred.get(check)];
1373
+ const [value, reasonCode] = result;
1374
+ return {
1375
+ check,
1376
+ subject: { check, page: read.plan, key: entries.length ? `tag:${tag}` : "tag" },
1377
+ result: value,
1378
+ reason_code: value === "pass" ? null : reasonCode,
1379
+ members: [],
1380
+ accept_eligible: value === "warning",
1381
+ coverage: {
1382
+ observed: value === "unexercised" ? 0 : 1,
1383
+ expected: 1,
1384
+ limits: value === "unexercised" ? [reasonCode] : [],
1385
+ loader_pins: read.loaderPins,
1386
+ },
1387
+ state: rowState(read, { reasonCode: value === "pass" ? null : reasonCode, members: [], extra: { tag: tag ?? null, literal_sha256: literalShas.length > 1 ? literalShas : literalShas[0] ?? null } }),
1388
+ };
1389
+ }