@adcp/sdk 16.2.0 → 16.4.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 (161) hide show
  1. package/AGENTS.md +2 -0
  2. package/dist/lib/adapters/version/3.1/assignments.d.mts +12 -0
  3. package/dist/lib/adapters/version/3.1/assignments.d.ts +12 -0
  4. package/dist/lib/adapters/version/3.1/assignments.js +67 -0
  5. package/dist/lib/adapters/version/3.1/assignments.mjs +43 -0
  6. package/dist/lib/adapters/version/3.1/bidding.d.mts +3 -0
  7. package/dist/lib/adapters/version/3.1/bidding.d.ts +3 -0
  8. package/dist/lib/adapters/version/3.1/bidding.js +115 -0
  9. package/dist/lib/adapters/version/3.1/bidding.mjs +90 -0
  10. package/dist/lib/adapters/version/3.1/delivery.d.mts +7 -0
  11. package/dist/lib/adapters/version/3.1/delivery.d.ts +7 -0
  12. package/dist/lib/adapters/version/3.1/delivery.js +78 -0
  13. package/dist/lib/adapters/version/3.1/delivery.mjs +54 -0
  14. package/dist/lib/adapters/version/3.1/index.d.mts +3 -0
  15. package/dist/lib/adapters/version/3.1/index.d.ts +3 -0
  16. package/dist/lib/adapters/version/3.1/index.js +36 -0
  17. package/dist/lib/adapters/version/3.1/index.mjs +12 -0
  18. package/dist/lib/adapters/version/index.d.mts +59 -7
  19. package/dist/lib/adapters/version/index.d.ts +59 -7
  20. package/dist/lib/adapters/version/index.js +51 -2
  21. package/dist/lib/adapters/version/index.mjs +49 -1
  22. package/dist/lib/adapters/version/negotiate.d.mts +71 -0
  23. package/dist/lib/adapters/version/negotiate.d.ts +71 -0
  24. package/dist/lib/adapters/version/negotiate.js +101 -0
  25. package/dist/lib/adapters/version/negotiate.mjs +73 -0
  26. package/dist/lib/adapters/version/schema-projection.d.mts +33 -0
  27. package/dist/lib/adapters/version/schema-projection.d.ts +33 -0
  28. package/dist/lib/adapters/version/schema-projection.js +294 -0
  29. package/dist/lib/adapters/version/schema-projection.mjs +269 -0
  30. package/dist/lib/adapters/version/translate.d.mts +122 -0
  31. package/dist/lib/adapters/version/translate.d.ts +122 -0
  32. package/dist/lib/adapters/version/translate.js +169 -0
  33. package/dist/lib/adapters/version/translate.mjs +141 -0
  34. package/dist/lib/adapters/version/types.d.mts +51 -10
  35. package/dist/lib/adapters/version/types.d.ts +51 -10
  36. package/dist/lib/core/AgentClient.d.mts +1 -1
  37. package/dist/lib/core/AgentClient.d.ts +1 -1
  38. package/dist/lib/core/AgentClient.js +17 -1
  39. package/dist/lib/core/AgentClient.mjs +17 -1
  40. package/dist/lib/core/ConversationTypes.d.mts +7 -0
  41. package/dist/lib/core/ConversationTypes.d.ts +7 -0
  42. package/dist/lib/core/SingleAgentClient.d.mts +82 -0
  43. package/dist/lib/core/SingleAgentClient.d.ts +82 -0
  44. package/dist/lib/core/SingleAgentClient.js +336 -15
  45. package/dist/lib/core/SingleAgentClient.mjs +350 -17
  46. package/dist/lib/core/TaskExecutor.d.mts +1 -1
  47. package/dist/lib/core/TaskExecutor.d.ts +1 -1
  48. package/dist/lib/core/TaskExecutor.js +182 -31
  49. package/dist/lib/core/TaskExecutor.mjs +183 -32
  50. package/dist/lib/core/account-reduction.d.mts +36 -0
  51. package/dist/lib/core/account-reduction.d.ts +36 -0
  52. package/dist/lib/core/account-reduction.js +127 -0
  53. package/dist/lib/core/account-reduction.mjs +98 -0
  54. package/dist/lib/core/buyer-account-registry.d.mts +52 -0
  55. package/dist/lib/core/buyer-account-registry.d.ts +52 -0
  56. package/dist/lib/core/buyer-account-registry.js +142 -6
  57. package/dist/lib/core/buyer-account-registry.mjs +142 -6
  58. package/dist/lib/errors/index.d.mts +34 -0
  59. package/dist/lib/errors/index.d.ts +34 -0
  60. package/dist/lib/errors/index.js +11 -0
  61. package/dist/lib/errors/index.mjs +10 -0
  62. package/dist/lib/index.d.mts +1 -1
  63. package/dist/lib/index.d.ts +1 -1
  64. package/dist/lib/index.js +2 -0
  65. package/dist/lib/index.mjs +2 -0
  66. package/dist/lib/media-buy/action-contracts.js +4 -4
  67. package/dist/lib/media-buy/action-contracts.mjs +4 -4
  68. package/dist/lib/media-buy/action-types.d.mts +3 -2
  69. package/dist/lib/media-buy/action-types.d.ts +3 -2
  70. package/dist/lib/media-buy/compatibility.d.mts +1 -0
  71. package/dist/lib/media-buy/compatibility.d.ts +1 -0
  72. package/dist/lib/media-buy/compatibility.js +33 -10
  73. package/dist/lib/media-buy/compatibility.mjs +32 -14
  74. package/dist/lib/media-buy/update-fields.generated.d.mts +3 -2
  75. package/dist/lib/media-buy/update-fields.generated.d.ts +3 -2
  76. package/dist/lib/negotiation/buyer.js +11 -5
  77. package/dist/lib/negotiation/buyer.mjs +11 -5
  78. package/dist/lib/negotiation/types.d.mts +4 -2
  79. package/dist/lib/negotiation/types.d.ts +4 -2
  80. package/dist/lib/negotiation/verification.d.mts +15 -6
  81. package/dist/lib/negotiation/verification.d.ts +15 -6
  82. package/dist/lib/negotiation/verification.js +146 -27
  83. package/dist/lib/negotiation/verification.mjs +146 -28
  84. package/dist/lib/negotiation/version.d.mts +6 -0
  85. package/dist/lib/negotiation/version.d.ts +6 -0
  86. package/dist/lib/negotiation/version.js +60 -0
  87. package/dist/lib/negotiation/version.mjs +33 -0
  88. package/dist/lib/registry/types.generated.d.mts +1 -1
  89. package/dist/lib/registry/types.generated.d.ts +1 -1
  90. package/dist/lib/server/create-adcp-server.d.mts +21 -0
  91. package/dist/lib/server/create-adcp-server.d.ts +21 -0
  92. package/dist/lib/server/create-adcp-server.js +98 -16
  93. package/dist/lib/server/create-adcp-server.mjs +84 -2
  94. package/dist/lib/server/error-arm-tools.d.mts +10 -10
  95. package/dist/lib/server/error-arm-tools.d.ts +10 -10
  96. package/dist/lib/server/error-arm-tools.js +22 -58
  97. package/dist/lib/server/error-arm-tools.mjs +30 -65
  98. package/dist/lib/server/index.d.mts +1 -1
  99. package/dist/lib/server/index.d.ts +1 -1
  100. package/dist/lib/storage/interfaces.d.mts +2 -0
  101. package/dist/lib/storage/interfaces.d.ts +2 -0
  102. package/dist/lib/testing/compliance/comply.js +17 -0
  103. package/dist/lib/testing/compliance/comply.mjs +17 -0
  104. package/dist/lib/testing/index.d.mts +3 -0
  105. package/dist/lib/testing/index.d.ts +3 -0
  106. package/dist/lib/testing/index.js +44 -2
  107. package/dist/lib/testing/index.mjs +42 -0
  108. package/dist/lib/testing/storyboard/collection-selection.d.mts +259 -0
  109. package/dist/lib/testing/storyboard/collection-selection.d.ts +259 -0
  110. package/dist/lib/testing/storyboard/collection-selection.js +384 -0
  111. package/dist/lib/testing/storyboard/collection-selection.mjs +348 -0
  112. package/dist/lib/testing/storyboard/context.js +12 -6
  113. package/dist/lib/testing/storyboard/context.mjs +12 -6
  114. package/dist/lib/testing/storyboard/fixture-resolution.d.mts +39 -1
  115. package/dist/lib/testing/storyboard/fixture-resolution.d.ts +39 -1
  116. package/dist/lib/testing/storyboard/fixture-resolution.js +216 -12
  117. package/dist/lib/testing/storyboard/fixture-resolution.mjs +218 -12
  118. package/dist/lib/testing/storyboard/index.d.mts +9 -2
  119. package/dist/lib/testing/storyboard/index.d.ts +9 -2
  120. package/dist/lib/testing/storyboard/index.js +51 -2
  121. package/dist/lib/testing/storyboard/index.mjs +49 -1
  122. package/dist/lib/testing/storyboard/prepared-fixtures.d.mts +471 -0
  123. package/dist/lib/testing/storyboard/prepared-fixtures.d.ts +471 -0
  124. package/dist/lib/testing/storyboard/prepared-fixtures.js +434 -0
  125. package/dist/lib/testing/storyboard/prepared-fixtures.mjs +389 -0
  126. package/dist/lib/testing/storyboard/protected-context.d.mts +127 -0
  127. package/dist/lib/testing/storyboard/protected-context.d.ts +127 -0
  128. package/dist/lib/testing/storyboard/protected-context.js +301 -0
  129. package/dist/lib/testing/storyboard/protected-context.mjs +267 -0
  130. package/dist/lib/testing/storyboard/rejection-hints.js +13 -4
  131. package/dist/lib/testing/storyboard/rejection-hints.mjs +13 -4
  132. package/dist/lib/testing/storyboard/runner.d.mts +24 -0
  133. package/dist/lib/testing/storyboard/runner.d.ts +24 -0
  134. package/dist/lib/testing/storyboard/runner.js +1061 -105
  135. package/dist/lib/testing/storyboard/runner.mjs +1075 -106
  136. package/dist/lib/testing/storyboard/seeding.d.mts +15 -1
  137. package/dist/lib/testing/storyboard/seeding.d.ts +15 -1
  138. package/dist/lib/testing/storyboard/seeding.js +272 -12
  139. package/dist/lib/testing/storyboard/seeding.mjs +274 -13
  140. package/dist/lib/testing/storyboard/types.d.mts +207 -10
  141. package/dist/lib/testing/storyboard/types.d.ts +207 -10
  142. package/dist/lib/testing/storyboard/types.js +1 -1
  143. package/dist/lib/testing/storyboard/types.mjs +1 -1
  144. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  145. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  146. package/dist/lib/testing/storyboard/validations.js +95 -0
  147. package/dist/lib/testing/storyboard/validations.mjs +95 -0
  148. package/dist/lib/validation/bundled-schema-store.js +5 -0
  149. package/dist/lib/validation/bundled-schema-store.mjs +4 -0
  150. package/dist/lib/validation/schema-loader.js +19 -2
  151. package/dist/lib/validation/schema-loader.mjs +22 -3
  152. package/dist/lib/version.d.mts +3 -3
  153. package/dist/lib/version.d.ts +3 -3
  154. package/dist/lib/version.js +3 -3
  155. package/dist/lib/version.mjs +3 -3
  156. package/docs/TYPE-SUMMARY.md +2 -2
  157. package/docs/guides/COLLECTION-FIXTURE-FOUNDATION.md +850 -0
  158. package/docs/guides/PROPOSAL-TERMS-VERIFICATION.md +66 -0
  159. package/docs/llms.txt +4 -3
  160. package/docs/migration-14.x-rc-worksheet.md +4 -4
  161. package/package.json +1 -1
@@ -0,0 +1,850 @@
1
+ # Collection fixture foundation
2
+
3
+ > **Who should read what.** Sections 1, 2 and 5 are for storyboard authors.
4
+ > Section 3 is SDK-internal rationale. Section 4 is for hosted and
5
+ > multi-tenant integrators, and its integration hold is a requirement, not
6
+ > optional hardening.
7
+
8
+ Five related additions to the storyboard runner, for storyboards that have to
9
+ pin a **real** collection-bearing product before they buy anything:
10
+
11
+ 1. a bounded `collection_selection_satisfies` fixture predicate that qualifies
12
+ candidates **before** deterministic selection;
13
+ 2. declaration-local captures that publish the selected product, its scoped
14
+ price and its collection witnesses from the **same** resolved graph;
15
+ 3. a protected `__adcp_fixture_` context namespace with one atomic write
16
+ funnel and detached snapshots;
17
+ 4. `prepareStoryboardFixtures()` — reusable, immutable preparation state that
18
+ both `runStoryboard` and `runStoryboardStep` accept;
19
+ 5. `collection_set_equals_context` — namespace-aware set comparison through
20
+ the same normalizer.
21
+
22
+ Everything here is additive. Storyboards that do not use the new declarations
23
+ behave exactly as before.
24
+
25
+ > **Upstream contract:** [adcontextprotocol/adcp#8259](https://github.com/adcontextprotocol/adcp/pull/8259).
26
+ > That packet is a proposal measured against SDK 15.9.5; the behavior
27
+ > described below is this SDK's implementation, re-verified on its own base.
28
+
29
+ ---
30
+
31
+ ## 1. Qualify before you select
32
+
33
+ The existing match DSL is closed, and `equals` is JSON deep equality. That is
34
+ a problem for `collection_targeting_allowed`, which **defaults to false when
35
+ omitted**: `equals: false` matches an explicit `false` and skips an omitted
36
+ flag that means exactly the same thing. Teaching every `equals` comparison to
37
+ apply schema defaults would silently rewrite the matcher contract, so the
38
+ default lives in one explicit predicate instead.
39
+
40
+ ```yaml
41
+ fixture_resolution:
42
+ products:
43
+ - handle: fixed_bundle_product
44
+ strategies: [discover]
45
+ match:
46
+ - path: '' # the whole candidate product
47
+ operator: collection_selection_satisfies
48
+ value:
49
+ collection_targeting: fixed # literal false OR absent. `true` does not qualify.
50
+ minimum_pairs: 1
51
+ ```
52
+
53
+ | Operand | Values | Meaning |
54
+ | --- | --- | --- |
55
+ | `collection_targeting` | `fixed` | `collection_targeting_allowed` is literal `false` **or** absent. |
56
+ | | `selectable` | literal `true` only. |
57
+ | | `any` | either, as long as the flag is boolean or absent. |
58
+ | `minimum_pairs` | `1` (default) | one distinct concrete `(publisher_domain, collection_id)` pair. |
59
+ | | `2` | two distinct pairs — required before a strict subset exists. |
60
+ | `require_cross_publisher_collection` | `false` (default) / `true` | require a collection namespace outside every declared property namespace. |
61
+
62
+ A non-boolean flag never qualifies in any mode. That is a schema failure of
63
+ the seller's catalog — surfaced by response-schema validation during
64
+ discovery — not something the predicate repairs.
65
+
66
+ **Qualification runs before UTF-8 identity ordering.** A nonqualifying
67
+ earlier product cannot conceal a later qualifying one, and the full paginated,
68
+ schema-validated catalog is enumerated first. Discovery, schema, transport and
69
+ pagination errors stay terminal; a complete no-match is still an explicit
70
+ `fixture_unsatisfied` coverage gap, never a pass.
71
+
72
+ ### Independent scopes
73
+
74
+ `minimum_pairs: 1` and `minimum_pairs: 2` qualify **independently**. A seller
75
+ whose only selectable product carries one collection still gets default
76
+ materialization coverage; the absence of a two-pair subset fixture does not
77
+ suppress it. Author them as separate declarations (or separate storyboards),
78
+ not as one declaration that needs both.
79
+
80
+ ### Cross-publisher carriage
81
+
82
+ `require_cross_publisher_collection: true` looks for a collection selector
83
+ whose publisher domain is outside **every** `publisher_domain` declared
84
+ across `publisher_properties[]` **of that same product**. That is the whole
85
+ definition: it is structural, computed from one catalog row, with no network
86
+ I/O, no `adagents.json` fetch and no registry lookup. It does not mean the
87
+ collection intrinsically spans publishers, and it is not evidence of
88
+ beneficial ownership, AOR, rights-holder status, host authorization or a
89
+ verified supply path. A product declaring no property domains is ineligible
90
+ rather than vacuously qualifying. All selectors are traversed; index zero is not
91
+ special.
92
+
93
+ Only the singular `publisher_domain` form is read. `core/product.json`
94
+ restricts each entry with `{"not": {"required": ["publisher_domains"]}}` and
95
+ tells SDK implementers to enforce singular-only at runtime, so the compact
96
+ fan-out form is rejected on products. Honoring it would also cut the wrong
97
+ way: a larger host set makes the relation strictly harder to satisfy, which
98
+ would silently turn a non-conformant catalog into a coverage gap.
99
+
100
+ A product whose `collections[]` contains a domain-only bulk-grant entry does
101
+ not qualify at all. `core/product.json` requires `collection_ids` on every
102
+ entry, and accepting one would put a schema-invalid `{publisher_domain}`
103
+ selector into the witness bundle that storyboards substitute whole into
104
+ `collection_selection.collections`.
105
+
106
+ This establishes a **declared, wire-visible relationship**. It is not
107
+ verified beneficial ownership, not `adagents.json` host authorization, and not
108
+ a verified supply path. Keep assertions about those outside this predicate;
109
+ they need their own normative authority.
110
+
111
+ ---
112
+
113
+ ## 2. Captures from the same selected graph
114
+
115
+ Without captures, a storyboard that needs the selected product's collections
116
+ or pricing has two bad options: substitute literal authored values, or read
117
+ `products[0]` from another `get_products` call — which is a *different*
118
+ product with different selectors, a different currency and a different
119
+ minimum spend.
120
+
121
+ ```yaml
122
+ fixture_resolution:
123
+ products:
124
+ - handle: selectable_product
125
+ strategies: [discover]
126
+ match:
127
+ - { path: '', operator: collection_selection_satisfies, value: { collection_targeting: selectable, minimum_pairs: 2 } }
128
+ context_outputs:
129
+ - { key: __adcp_fixture_product_id, path: product_id }
130
+ - { key: __adcp_fixture_bundle, witness: declared_selectors }
131
+ - { key: __adcp_fixture_subset, witness: strict_subset }
132
+ - { key: __adcp_fixture_unknown, witness: undeclared_collection_id_selectors }
133
+ - { key: __adcp_fixture_foreign, witness: undeclared_publisher_domain_selectors }
134
+ pricing_options:
135
+ - handle: cpm_price
136
+ product_handle: selectable_product
137
+ strategies: [discover]
138
+ match:
139
+ - { path: /pricing_model, operator: equals, value: cpm }
140
+ context_outputs:
141
+ # `currency` is REQUIRED on cpm-option.json, so a path capture is safe.
142
+ - { key: __adcp_fixture_currency, path: currency }
143
+ # `fixed_price` and `min_spend_per_package` are OPTIONAL — capture the
144
+ # whole option so a legal absence stays an absence. See below.
145
+ - { key: __adcp_fixture_price, snapshot: selected_entity }
146
+ ```
147
+
148
+ **Choose the form before you write the entry.** `path:` when the match
149
+ *guarantees* the field; `witness:` for derived collection state; `snapshot:`
150
+ when absence is legal and meaningful. A `path:` capture of an optional field
151
+ turns a conformant seller into a setup failure.
152
+
153
+ `path:` resolves against the **selected entity snapshot** — the product or
154
+ pricing option the resolver pinned. `witness:` binds a bounded derived value
155
+ from the `collection_selection_satisfies` evaluation of that same candidate.
156
+
157
+ | `witness` | Value |
158
+ | --- | --- |
159
+ | `declared_selectors` | the product's complete concrete selector array |
160
+ | `distinct_pairs` | normalized `(publisher_domain, collection_id)` pairs |
161
+ | `strict_subset` | one-entry selector array; a strict subset when ≥ 2 distinct pairs exist, else the capture fails |
162
+ | `undeclared_collection_id_selectors` | one-entry array with an id verified absent from the product |
163
+ | `undeclared_publisher_domain_selectors` | one-entry array with a domain verified absent from every namespace the product declares |
164
+ | `cross_publisher_relation` | `{ collection_publisher_domain, property_publisher_domains[], selector }`; requires `require_cross_publisher_collection: true`. `selector.publisher_domain` always equals `collection_publisher_domain`. |
165
+ | `property_publisher_domains` | declared property namespaces, bytewise ordered |
166
+
167
+ When `require_cross_publisher_collection: true`, `strict_subset` and both negative
168
+ witnesses are drawn from the **retained external owner**, not from whichever
169
+ pair sorts first. A bytewise-earlier local pair would otherwise produce a
170
+ subset that exercises only the host's own inventory while the storyboard
171
+ believed it was testing carriage. Two distinct *total* pairs still suffice —
172
+ one external pair alongside a local one is a legitimate carriage product.
173
+
174
+ Negative witnesses are **generated locally and collision-checked against the
175
+ complete selected product**, not hardcoded. A hardcoded "surely-absent" id can
176
+ be a real collection at some seller; a generated one is verified absent, and
177
+ is deterministic so evidence reproduces.
178
+
179
+ Three rules that make captures trustworthy:
180
+
181
+ - **All-or-nothing.** Nothing is published until the *whole* fixture graph
182
+ resolves. A product that pinned but whose declared price did not leaves no
183
+ captures behind.
184
+ - **A capture that cannot be built is a setup FAILURE** (`status: failed`),
185
+ never an unsupported or unsatisfied downgrade. The author asked for state
186
+ the selected entity does not provide; proceeding would grade the agent
187
+ against a request nobody wrote.
188
+ - **Commercial terms come from the pinned graph.** `currency`,
189
+ `fixed_price` and `min_spend_per_package` describe the option the buy will
190
+ actually reference — so a 5 000 minimum is visible before the storyboard
191
+ sends a 1 000 budget.
192
+ - **A capture is only as good as its match.** Captures read the entity the
193
+ resolver pinned; they do not make a seller carry a field.
194
+
195
+ ### Optional fields: use a snapshot, not a presence gate
196
+
197
+ `fixed_price` and `min_spend_per_package` are both OPTIONAL on
198
+ `cpm-option.json` — absent `fixed_price` means auction pricing, absent
199
+ `min_spend_per_package` means no minimum. A `path:` capture of either turns a
200
+ legal supporting seller into a setup failure. Gating the declaration on
201
+ `present` is worse: it stops that seller qualifying at all.
202
+
203
+ Capture the whole selected entity instead:
204
+
205
+ ```yaml
206
+ context_outputs:
207
+ - { key: __adcp_fixture_price, snapshot: selected_entity }
208
+ ```
209
+
210
+ Absence then stays absence — the key is simply not on the captured object,
211
+ nothing is defaulted and no zero is fabricated.
212
+
213
+ ```yaml
214
+ skip_if: '!context.__adcp_fixture_price.min_spend_per_package'
215
+ ```
216
+
217
+ **That condition does not distinguish an explicit `0` from an absent field.**
218
+ Phase conditions support property lookup, truthiness, `!`, strict equality
219
+ against string/boolean literals, and `||` — there is no presence operator and
220
+ no numeric literal, so `0` and absent both take the same branch. The capture
221
+ preserves the difference; the condition grammar cannot express it. When the
222
+ difference matters, assert it on a validation (`field_value_or_absent`
223
+ against the response) rather than branching on it.
224
+
225
+ Use `path:` when the match *guarantees* the field (`product_id`, `currency`,
226
+ or a field you gated with a `present` predicate for a real eligibility
227
+ reason), and `snapshot:` when absence is legal and meaningful.
228
+
229
+ `path:` takes the dotted storyboard grammar (`currency`,
230
+ `pricing_options[0].rate`), not the RFC 6901 pointers `match[].path` takes —
231
+ a leading `/` is refused at load with a message naming the grammar. Every
232
+ dotted segment must name a field, so `"."`, `".."`, `"ext."`, `".ext"` and
233
+ `"a..b"` are refused too. `"."` in particular used to parse as two empty
234
+ segments, resolve to the whole selected entity and publish the entire
235
+ product snapshot under a scalar's key; if that is what you want, say so with
236
+ `snapshot: selected_entity`.
237
+
238
+ One authoring hazard worth naming: a whole-entity snapshot binds fields you
239
+ did not reason about. If a later validation compares a response field against
240
+ the snapshot, it is grading the seller against its own echoed catalog row —
241
+ self-consistency, not conformance. Compare against an authored expectation
242
+ when that is what you mean.
243
+
244
+ ### Using a capture
245
+
246
+ Captures are ordinary whole-key `$context` substitutions:
247
+
248
+ ```yaml
249
+ phases:
250
+ - id: buy
251
+ title: Buy
252
+ steps:
253
+ - id: create
254
+ title: Buy the pinned offer
255
+ task: create_media_buy
256
+ stateful: true
257
+ sample_request:
258
+ brand: { domain: acmeoutdoor.example }
259
+ start_time: asap
260
+ end_time: '2099-06-30T23:59:59Z'
261
+ currency: $context.__adcp_fixture_currency
262
+ packages:
263
+ # `cpm_price` is the authored handle; the runner rewrites it to the
264
+ # pinned seller id. The product capture is shown to illustrate the
265
+ # mechanism — the bare handle `selectable_product` works too.
266
+ - product_id: $context.__adcp_fixture_product_id
267
+ pricing_option_id: cpm_price
268
+ budget: 25000
269
+ targeting_overlay:
270
+ collection_selection:
271
+ mode: selected
272
+ collections: $context.__adcp_fixture_subset
273
+ validations:
274
+ - check: field_present
275
+ path: media_buy_id
276
+ description: The buy succeeds
277
+ ```
278
+
279
+ `declared_selectors`, `strict_subset` and both `undeclared_*` witnesses are
280
+ selector **arrays** and substitute directly into a `collections` field with
281
+ no transform. The other three are **diagnostic** shapes, not wire shapes:
282
+ `distinct_pairs` carries singular `collection_id` (not `collection_ids`),
283
+ `property_publisher_domains` is a `string[]`, and `cross_publisher_relation`
284
+ is a single object. Substituting `distinct_pairs` into `collections` passes
285
+ storyboard load and is rejected by the seller; feeding it to
286
+ `collection_set_equals_context` is caught with a named diagnostic rather
287
+ than diffed, because a missing `collection_ids` would otherwise read as a
288
+ domain-only bulk grant.
289
+
290
+ **`$context.<key>` matches a whole key only.** A snapshot can be *branched
291
+ on* in a phase condition, which does support dotted access, but it cannot be
292
+ *drilled into* for a request field:
293
+ `$context.__adcp_fixture_price.min_spend_per_package` in a `sample_request`
294
+ is not a supported substitution. The runner now reports it as an
295
+ `unresolved_substitution` rather than shipping the literal string, but the
296
+ fix is to capture the field with `path:` when you need it in a request —
297
+ which means gating it with a `present` predicate if it is optional, and
298
+ accepting that this narrows which sellers qualify.
299
+
300
+ ### Parent/price coherence
301
+
302
+ When a product declaration has dependent pricing-option declarations, a
303
+ candidate product only qualifies if it carries at least one pricing option
304
+ those children could bind. Without this, the bytewise-first product can win
305
+ the parent selection and strand the graph on a child another product would
306
+ have satisfied.
307
+
308
+ ---
309
+
310
+ ## 3. The protected `__adcp_fixture_` namespace
311
+
312
+ Capture keys must start with `__adcp_fixture_` and match
313
+ `^__adcp_fixture_[A-Za-z0-9_]+$`, so existing whole-key `$context.<key>`
314
+ substitution resolves them with no new syntax.
315
+
316
+ Inside that namespace:
317
+
318
+ - Values are **committed once** and are immutable for the rest of the run —
319
+ including to fixture preparation itself.
320
+ - Writes, deletes, provenance entries and unavailable-output markers all
321
+ commit through one funnel that validates the **whole patch** before
322
+ changing anything. A rejected patch lands nothing, including its ordinary
323
+ keys.
324
+ - Metadata is guarded alongside values. Marking a prepared capture
325
+ "unavailable" would suppress its consumer just as effectively as deleting
326
+ it.
327
+ - Committed values are deep-cloned on ingestion and deep-frozen; every read
328
+ for request building or diagnostics returns a detached clone. Guarding map
329
+ assignment alone is not enough — `injectContext` hands out `context[key]`
330
+ by reference, so a mutated request could otherwise write back into the
331
+ snapshot it was built from.
332
+
333
+ Everything outside the namespace keeps its existing semantics exactly,
334
+ including by-reference injection of ordinary keys.
335
+
336
+ Rejected at **load time**: storyboard `context` defaults, step
337
+ `context_outputs` (path, `generate`, and `task_completion.` forms), and
338
+ duplicate capture keys across declarations. Rejected at **run time**:
339
+ `options.context`, routed `comply()` route context, convention extractors,
340
+ and seller responses. A runtime violation becomes a failed
341
+ `protected_context_violation` validation on the step that attempted it.
342
+
343
+ Seller opaque context is still **not** implicitly merged. The real promotion
344
+ paths are fixed convention extractors and authored `context_outputs`, and
345
+ both are now funnel-guarded.
346
+
347
+ ---
348
+
349
+ ## 4. Reusable preparation
350
+
351
+ ```ts
352
+ import { prepareStoryboardFixtures, runStoryboardStep } from '@adcp/sdk/testing';
353
+
354
+ const prepared = await prepareStoryboardFixtures(agentUrl, storyboard, options);
355
+ if (prepared.disposition.status !== 'resolved') {
356
+ // Terminal. Carried forward verbatim on reuse — a retry must not launder a
357
+ // failed preparation into a freshly discovered alternative.
358
+ }
359
+
360
+ const step = await runStoryboardStep(agentUrl, storyboard, 'create', {
361
+ ...options,
362
+ prepared_fixtures: prepared,
363
+ // REQUIRED for any reuse — see § Authority below. Omitting it fails
364
+ // closed with zero seller calls.
365
+ prepared_fixtures_authorizer: lookup => authorize(lookup),
366
+ });
367
+ ```
368
+
369
+ `runStoryboard` accepts the same option and skips re-resolution.
370
+
371
+ The state exposes `binding`, `disposition`, `resolutions`, `coverageGaps`,
372
+ `selections`, `captures`, `captureProvenance`, `witnesses` and `phase`. Every
373
+ accessor returns a detached copy, including on a failed or no-match
374
+ preparation, and the objects handed to the constructor are cloned on
375
+ ingestion rather than retained. The fixture binding registry is deliberately
376
+ **not** on the public surface: it exposes live registration that could
377
+ re-point a pinned handle at a different seller entity.
378
+
379
+ ### Binding
380
+
381
+ Prepared state is pinned to the storyboard content digest, the resolved
382
+ route(s), the schema artifact, the contract version, the resolved account
383
+ scope, the sandbox flag, a caller-declared nonsecret principal, a credential
384
+ fingerprint, and the run id. Everything except `run_id` is re-compared on
385
+ reuse.
386
+
387
+ The credential fingerprint is an HMAC under a **per-process random key**
388
+ covering *every* credential dimension: the whole `headers` map (not just
389
+ `Authorization` — the SDK documents `x-api-key` / `x-org-id` as the canonical
390
+ tenant-scoping example), run and per-route `auth`, and the test-kit API key.
391
+ Absence is an explicit sentinel rather than an omitted field, so "prepared
392
+ with no credential" cannot compare equal to "prepared with one". No bearer
393
+ token, OAuth token, API key or credential object is ever serialized into a
394
+ binding, a capture, evidence or an error; OAuth binds to the client id rather
395
+ than the rotating access token, so legitimate refresh does not break reuse.
396
+
397
+ Two limits worth stating. The **schema artifact digest** covers the bundle
398
+ tree's file inventory at the moment that root was first resolved in this
399
+ process; it detects a different resolved root (a `withExternalSchemaRoot`
400
+ scope, a version change) but NOT an in-place mutation of a root already
401
+ observed, and it is not a publisher-signed content digest. The **account
402
+ scope** comes from `resolveAccount`, which currently derives `operator` from
403
+ `brand.domain` — one dimension, not two — and unbranded runs share the SDK's
404
+ default brand. Neither field is a substitute for the authorizer.
405
+
406
+ `credential_fingerprint` covers the run credential and the routed per-agent
407
+ credential, and is re-compared on reuse. A step's own `auth` directive is
408
+ reported separately as `step_credential_fingerprint`, present only when the
409
+ step in scope declares its own `auth` — an absent value means the step
410
+ inherits the run credential, not that there is no step. It is not diffed:
411
+ preparation has no step, so diffing it would refuse every legitimate reuse.
412
+ Alongside it, `step_auth_kind` (`'none' | 'override'`) is the nonsecret
413
+ discriminator an authorizer can actually branch on; the fingerprint itself is
414
+ keyed per process, so it shows you *that* the credential differs, never what
415
+ it is. The authorizer can still refuse on it — the
416
+ `security_baseline` probes deliberately dispatch unauthenticated or with an
417
+ invalid key, and that is now visible before the wire call rather than after.
418
+
419
+ Both are HMACs over the *resolved* credential, not its syntax. Two runs
420
+ whose `auth: { type: 'api_key', from_test_kit: true }` directives are
421
+ byte-identical produce different fingerprints when the kit key differs, so a
422
+ rotated or swapped credential cannot reuse state prepared under the old
423
+ one.
424
+
425
+ ### Authority — and what the SDK does not do
426
+
427
+ **The SDK does not implement a grant service.** Resolving a principal from a
428
+ session, storing grants and revoking them are host duties. What the SDK
429
+ guarantees:
430
+
431
+ - **No authorizer, no reuse.** Omitting `prepared_fixtures_authorizer` fails
432
+ closed, for direct in-process state exactly as for an opaque reference,
433
+ with zero seller calls. Trusted object identity proves a state came from
434
+ `prepareStoryboardFixtures` in this process; it does not establish a
435
+ current grant.
436
+ - **Every lookup is re-checked** — at run start and again before each step —
437
+ for *both* forms. A decision made at preparation time is never cached
438
+ forward, so a grant revoked after a successful step fails the next one.
439
+ - **The lookup carries the EFFECTIVE scope.** The account identity is read
440
+ from the same `context.account` / `resolveAccount` the discovery call uses,
441
+ so two accounts sharing a brand but differing by operator, unit or
442
+ `account_id` produce different bindings. When the step's own
443
+ `sample_request.account` authors an `account_id` or `operator` — which
444
+ `resolveMediaBuyAccount` lets win over the context account — the binding
445
+ reports it separately as `request_account_scope`, so `account_scope` is
446
+ never the only account the authorizer is shown. The credential
447
+ fingerprint folds in the step's own `auth` directive **and** the run's
448
+ `test_kit.auth`, so a directive whose text is unchanged but whose
449
+ `from_test_kit` resolution now yields a different credential still
450
+ changes the fingerprint; a step that dispatches unauthenticated or with a
451
+ deliberately invalid key is visible to the authorizer *before* the wire
452
+ call rather than after.
453
+ - **The authorizer runs BEFORE binding coherence**, and a refusal names only
454
+ the binding *fields* that differ, never their values. Diffing first would
455
+ answer an unauthorized caller's guessed reference with a report describing
456
+ another principal's routes, account and credential fingerprint — and turn
457
+ that fingerprint into a credential-confirmation oracle.
458
+ - **Authorization precedes any seller contact at all** — before clients are
459
+ constructed and before capability discovery, not merely before the
460
+ mutating calls. True for `runStoryboard`, `runStoryboardStep` and
461
+ `prepareStoryboardFixtures`. `comply()` is **not** a reuse entry point and
462
+ refuses `prepared_fixtures` outright with zero seller contact: prepared
463
+ state binds one storyboard digest, and comply picks its storyboard set
464
+ from its own capability discovery, so there is no scope to authorize
465
+ against before the agent is reached. Call `prepareStoryboardFixtures()`
466
+ and pass the state per storyboard. The binding needs no seller input, so there is no reason
467
+ for an unauthorized caller to reach the agent's discovery endpoint, let
468
+ alone the `comply_test_controller` reset and `seed_*` mutations that
469
+ normally run before the first phase.
470
+ - **Trust is runtime object identity.** A `structuredClone`, a JSON
471
+ round-trip, a hand-built look-alike with the right public shape, a
472
+ prototype-only object, a sealed reference, a `context_provenance` map, a
473
+ principal string in caller JSON, and a seller-authored authorization
474
+ boolean all fail `isTrustedPreparedFixtures`. The class value is exported
475
+ for `instanceof`, but its constructor requires a module-private token, so
476
+ `new PreparedStoryboardFixtures(...)` from caller code throws rather than
477
+ minting trusted state — `@internal` on a constructor is documentation, not
478
+ access control.
479
+ - **A malformed decision is never consent.** Authorization requires
480
+ literally `{ authorized: true }`. `undefined`, `null`, `{}`,
481
+ `{ authorized: 'yes' }`, `{ authorized: 1 }`, a thrown error, a rejected
482
+ promise and an `authorized` getter that throws all refuse, with zero
483
+ seller calls. The host's error text is never echoed into the result —
484
+ a grant store's connection string is not a compliance finding.
485
+ - **Opaque references fail closed without an authorizer.** The SDK has no
486
+ session and no grant store; refusing is the only honest answer.
487
+ - **Every reference-shaped refusal reads the same.** Not-found, untrusted,
488
+ wrong contract version and your-authorizer-said-no all answer
489
+ `prepared fixture state was refused by the configured authorizer` with
490
+ `actual: authorizer_denied` and an identical validation shape, and the
491
+ authorizer is consulted (answer discarded) even on the not-found path so
492
+ the call count matches. Your own `detail` is not echoed back on this
493
+ path — a denial that reads "not yours" would otherwise confirm the
494
+ reference existed. Direct state keeps its specific codes: that caller
495
+ already holds the object and learns nothing from them.
496
+ **This is not a constant-time guarantee.** The resolving path does more
497
+ work (it clones the prepared binding and reads your decision), and your
498
+ own resolver's lookup time is not something the SDK can bound, so a
499
+ patient attacker with many samples can still separate the two by timing.
500
+ If your threat model includes that, pad inside your resolver.
501
+
502
+ **If you are the legitimate operator and you hit this**, the SDK cannot
503
+ tell you which arm fired — but your own authorizer can. It is called on
504
+ every path, including not-found (where its answer is discarded), so log
505
+ the `PreparedFixturesLookup` it received. If it was never called at all,
506
+ the refusal is `authorizer_required`. And if you hold the state object,
507
+ pass it directly rather than by reference: the direct form keeps
508
+ `untrusted_state`, `contract_version_mismatch` and `binding_mismatch`
509
+ with their differing-field lists.
510
+
511
+ One thing the uniformity does **not** cover: the authorizer is handed
512
+ the stored binding on a resolving reference and a second copy of
513
+ `current` on a missing one, so an authorizer that deep-compares them can
514
+ tell the two apart. That is not a leak — the authorizer is the host, and
515
+ the store it would be learning about is its own. What is closed is the
516
+ oracle available to the caller, who sees only the refusal.
517
+ - **A refusal is an access-control error, not a grading outcome.** When a
518
+ gate refuses, `runStoryboard` / `runStoryboardStep` **throw**
519
+ `PreparedFixturesDeniedError` and return nothing. The error carries
520
+ `refusalCode` — one of `authorizer_required`, `untrusted_state`,
521
+ `contract_version_mismatch`, `authorizer_denied`, `binding_mismatch`,
522
+ `unauthorized_metadata` — and nothing else: no grading result, partial
523
+ or redacted, and so no fixture identities, `seller_ids`, captures,
524
+ binding values, request/response records, or results from steps that
525
+ ran before the grant was revoked. A hosted route should surface it as a
526
+ 403-style denial.
527
+
528
+ ```ts
529
+ import { PreparedFixturesDeniedError } from '@adcp/sdk/testing';
530
+
531
+ try {
532
+ return await runStoryboardStep(agentUrl, storyboard, stepId, options);
533
+ } catch (error) {
534
+ if (error instanceof PreparedFixturesDeniedError) {
535
+ return res.status(403).json({ error: 'forbidden', refusal_code: error.refusalCode });
536
+ }
537
+ throw error;
538
+ }
539
+ ```
540
+
541
+ - **The evidence still has to exist somewhere accountable.** The runner
542
+ builds the COMPLETE, unredacted record first — the synthesized
543
+ `prepared_fixtures_authorization` check, `fixture_resolutions` with
544
+ `seller_ids`, exact request and response records for every step that
545
+ ran, the reserved captures — and hands it to
546
+ `prepared_fixtures_audit_sink` before throwing. That is an authorized
547
+ scope inside your trust boundary; it must not be routed back to the
548
+ refused caller. Configure no sink and the record is simply not produced
549
+ to anyone. A sink that throws does not change the outcome: the denial
550
+ is thrown either way, and the sink's own failure is never reported to
551
+ the caller, since that would be an oracle over host state.
552
+
553
+ In that retained record the refusal check uses explicit `request: null`,
554
+ `response: null`, `json_pointer: null` and `duration_ms: 0`. Explicit,
555
+ not omitted — a missing key cannot be told apart from a runner that
556
+ forgot to record one.
557
+
558
+ **There is no `[redacted]` projection of a refused run any more.** An
559
+ earlier design returned a redacted report to the refused caller; the
560
+ contract now forbids both halves of that — no result for the caller,
561
+ and no marker standing in for required evidence in the record. Nothing
562
+ in the SDK redacts on the refusal path.
563
+
564
+ ```ts
565
+ await runStoryboardStep(agentUrl, storyboard, stepId, {
566
+ ...options,
567
+ // `withPreparedFixtures` makes the pair atomic: the authorizer is
568
+ // REQUIRED, and passing the two fields separately lets you forget it
569
+ // until the run refuses. The raw fields still work.
570
+ ...withPreparedFixtures({ ref, resolve: ref => hostStore.get(ref) }, async ({ binding, current, step_id, reference }) => {
571
+ // Resolve principal/tenant/run from TRUSTED SERVER SESSION STATE, never
572
+ // from the request body. Re-check grant, revocation and expiry, and
573
+ // re-check effective per-step credential scope after routing/auth
574
+ // overrides.
575
+ const grant = await grants.lookupLive(session.tenantId, reference);
576
+ if (!grant || grant.revoked) return { authorized: false, detail: 'grant revoked' };
577
+ if (grant.accountScope !== current.account_scope) return { authorized: false, detail: 'account mismatch' };
578
+
579
+ // The ONLY meaningful credential comparison is within `current`. Both
580
+ // fingerprints are HMACs under a per-process key, so a value you
581
+ // persisted from an earlier process is not comparable — but a step whose
582
+ // effective credential differs from the run's is visible here, before
583
+ // the wire call. `step_auth_kind` is the nonsecret discriminator.
584
+ if (current.step_auth_kind && !grant.allowsProbeSteps) {
585
+ return { authorized: false, detail: `step overrides the run credential (${current.step_auth_kind})` };
586
+ }
587
+ return { authorized: true };
588
+ }),
589
+ });
590
+ ```
591
+
592
+ `runStoryboardStep` executes a named step **without** evaluating its
593
+ enclosing `phase.skip_if` — so a storyboard that branches on a snapshot the
594
+ way § 2 shows will run the step anyway on this path. See *What this does not
595
+ solve*.
596
+
597
+ Your authorizer's `detail` is echoed to the caller on the **direct-state**
598
+ form only, never on the reference form. If you reuse one denial string for
599
+ both, keep it free of anything you would not show an unauthorized caller.
600
+
601
+ ### Process scope — an explicit limit, not an oversight
602
+
603
+ Prepared state is **process-local**. Its trust is a live object identity and
604
+ its credential fingerprint is keyed by a per-process random key, so it does
605
+ not survive serialization and is not meant to. A frozen JSON snapshot that
606
+ crossed a process boundary carries no evidence that its grant is still live.
607
+
608
+ Durable, multi-process or cross-host reuse needs either authenticated
609
+ reconstruction (run `prepareStoryboardFixtures` again under the authenticated
610
+ scope) or a host-side reference lookup that re-checks live scope on every
611
+ use. Accepting a rehydrated snapshot is not an option the SDK offers.
612
+
613
+ ### Integration hold
614
+
615
+ `prepared_fixtures_authorizer` is a **required integration point for hosted
616
+ multi-tenant callers**, not an optional hardening step. Without it, the SDK
617
+ enforces binding coherence only — which observes a reroute, an account
618
+ switch, a schema swap or a credential change, but **cannot observe
619
+ revocation**. That gap is the host's to close. Shipping a hosted step route
620
+ that forwards prepared state without an authorizer is an unenforced
621
+ authorization boundary, and this SDK does not claim otherwise.
622
+
623
+ There is no warn-only path: reuse without an authorizer is refused, not
624
+ degraded. A single-tenant CLI run that genuinely has no grant model should
625
+ call `runStoryboard` without `prepared_fixtures` at all, or supply an
626
+ authorizer that returns `{ authorized: true }` as an explicit, reviewable
627
+ decision rather than an omission.
628
+
629
+ ---
630
+
631
+ ## 5. `collection_set_equals_context`
632
+
633
+ ```yaml
634
+ validations:
635
+ - check: collection_set_equals_context
636
+ path: packages[0].targeting_overlay.collection_selection.collections
637
+ context_key: __adcp_fixture_subset
638
+ description: The readback restates exactly the collections we selected
639
+ ```
640
+
641
+ Both sides are normalized to atomic `(publisher_domain, collection_id)` pairs
642
+ and compared as exact sets after bytewise pair ordering. Outer grouping and
643
+ inner id order are irrelevant; namespaces never are.
644
+
645
+ - IDs are never flattened without their domain — two publishers may declare
646
+ the same `collection_id`.
647
+ - Schema-valid redundancy (a repeated id, an overlapping pair split across
648
+ two groups) collapses to one pair. `product.collections[]` and
649
+ `collection_ids[]` carry no uniqueness constraint, so redundancy is legal
650
+ *product composition*.
651
+ - The readback arm is stricter: `core/collection-selection.json` declares
652
+ `uniqueItems: true` on `collections` in `mode: selected`. Normalizing here
653
+ is for COMPARISON only and never excuses that — ordinary response-schema
654
+ validation is what rejects a duplicated outer entry, and this check does
655
+ not replace it.
656
+ - Empty-string ids are legal and preserved — the schema declares no
657
+ `minLength`. Nothing is case-folded, trimmed or punycoded. Unicode
658
+ identifiers are byte-exact.
659
+ - The domain-only bulk-grant selector (`{ publisher_domain }` with no
660
+ `collection_ids`) is a **distinct** selector, not an empty enumeration, and
661
+ does not count toward `minimum_pairs`.
662
+ - Distinct pair cardinality defines a proper subset.
663
+
664
+ Failure modes have distinct diagnostics: `expected_context_absent`,
665
+ `expected_context_malformed`, `actual_collections_malformed`, or a set
666
+ difference listing missing and unexpected pairs. Normalization is bounded at
667
+ `COLLECTION_PAIR_LIMIT` (10 000) distinct pairs and diagnostics at
668
+ `COLLECTION_DIAGNOSTIC_LIMIT` (20) rendered pairs; exceeding the pair bound is
669
+ a **visible failure**, never truncation that could make unequal sets compare
670
+ equal.
671
+
672
+ **Unlike the branch-safe `field_equals_context` family, a missing
673
+ `context_key` fails.** A mandatory set comparison that passes when its
674
+ expected state was never captured certifies exactly the substitution it
675
+ exists to catch.
676
+
677
+ The validator runs through the shared per-step engine, so `runStoryboard` and
678
+ `runStoryboardStep` grade it identically.
679
+
680
+ ---
681
+
682
+ ## Upstream amendment this depends on
683
+
684
+ **Six** new names are not yet in the published AdCP contracts:
685
+
686
+ | Name | Section that must list it |
687
+ | --- | --- |
688
+ | `collection_selection_satisfies` | `storyboard-schema.yaml` — closed match-operator set |
689
+ | `fixture_resolution[].context_outputs` | `storyboard-schema.yaml` — `fixture_resolution` grammar |
690
+ | `collection_set_equals_context` | `runner-output-contract.yaml` — `authored_check_kinds` |
691
+ | `fixture_capture_unbuildable` | `runner-output-contract.yaml` — runner-SYNTHESIZED check kinds |
692
+ | `protected_context_violation` | `runner-output-contract.yaml` — runner-SYNTHESIZED check kinds |
693
+ | `prepared_fixtures_authorization` | `runner-output-contract.yaml` — runner-SYNTHESIZED check kinds |
694
+
695
+ Four further amendment items are not new *names* but are normative, and a
696
+ third-party runner cannot infer them:
697
+
698
+ | Item | Why the contract must state it |
699
+ | --- | --- |
700
+ | The reserved `__adcp_fixture_` prefix, and the `__proto__` / `constructor` / `prototype` rejection | An authoring constraint on **every** storyboard's `context`, `context_outputs[].key` and `not_applicable_if[].context_keys` — not only ones using this feature. Without it a third-party author has no way to learn the prefix is reserved, and a third-party runner accepts what this one rejects at load. |
701
+ | A carve-out from the `context_key_absent` family rule | `runner-output-contract.yaml` note #2 mandates `passed: true` + `observations:` when a named context key is absent. `collection_set_equals_context` deliberately **fails** instead, because fixture captures publish all-or-nothing so absence means the graph never resolved — not a skipped branch. A runner implementing the family rule verbatim would certify exactly the substitution the check exists to catch. |
702
+ | `path: ''` as the whole-candidate-entity pointer | Every existing match operator targets a sub-path; a third-party runner could reasonably reject an empty pointer. |
703
+ | The second parent-selection exclusion | `storyboard-schema.yaml` pins deterministic selection as "exclude candidates already pinned to non-reusable handles, sort by UTF-8 bytewise seller identity, select the first" — one exclusion. Skipping a parent that cannot satisfy a dependent discover-only pricing spec is a second, and it changes which product a conformant seller is tested on. |
704
+
705
+ `collection_set` and `minimum_pairs` are zero-occurrence in
706
+ `schemas/cache/3.2.5/` — they are SDK-local storyboard DSL, not wire
707
+ objects, and the amendment should say so or a reader will take
708
+ `collection_set_equals_context` to assert a wire shape.
709
+
710
+ The last three check kinds matter because the contract closes the synthesized side too
711
+ (`capture_path_not_resolvable | unresolved_substitution`), and a report
712
+ consumer keying on that enum will see three unknown values. The amendment
713
+ also needs the per-kind `expected` / `json_pointer` / `request` / `response`
714
+ grammar for them, not just the name lists.
715
+
716
+ Until a companion `adcontextprotocol/adcp` PR lands all six, no *published*
717
+ storyboard can legally use any of this. That is a merge dependency, not an
718
+ implementation gap — and it is why the downgrade story below matters.
719
+
720
+ ### Output inventory for the amendment
721
+
722
+ Everything a report consumer can newly observe, with where it is emitted and
723
+ what the surrounding fields carry.
724
+
725
+ **Eight runner-SYNTHESIZED emission sites, plus one AUTHORED validator that
726
+ is counted separately.** The authored kind is not a synthesized site and
727
+ must not be folded into that total — it belongs in the contract's
728
+ `authored_check_kinds`, never in its closed synthesized list.
729
+
730
+ Line numbers rot; these find the sites in any revision.
731
+
732
+ | Group | Count | Find them with |
733
+ | --- | --- | --- |
734
+ | synthesized — capture | 3 | `grep -n "check: 'fixture_capture_unbuildable'" src/lib/testing/storyboard/{seeding,runner}.ts` — the seed/discovery step, the funnel's own commit refusal, and a standalone step reusing a failed preparation |
735
+ | synthesized — protection | 2 | `grep -n "check: 'protected_context_violation'" src/lib/testing/storyboard/runner.ts` — the shared graded-commit helper (covering four funnel call sites) and the ordinary step's context patch |
736
+ | synthesized — authority | 3 | `grep -n "check: 'prepared_fixtures_authorization'" src/lib/testing/storyboard/runner.ts` — the per-step gate, the run and pass gates, and the standalone step entry gate |
737
+ | **authored — separate** | 1 | `grep -n "collection_set_equals_context" src/lib/testing/storyboard/validations.ts` — dispatch and implementation |
738
+
739
+ `schema_id` and `schema_url` are `required_on_failure` for every check and
740
+ MUST be `null` outside `response_schema`, so all four kinds carry them as
741
+ explicit nulls. Omitting the keys is not the same as recording `null`: a
742
+ consumer cannot tell "no schema applies" from "the runner forgot".
743
+
744
+ `json_pointer` is `null` on `protected_context_violation` and
745
+ `prepared_fixtures_authorization`: neither is anchored to a location in a
746
+ seller response, and inventing a pointer would make a setup failure look
747
+ like a response defect. `fixture_capture_unbuildable` is the exception —
748
+ when the failure is a declared `path:` that did not resolve, it carries the
749
+ RFC 6901 form of that path, matching what the contract requires of its
750
+ analogue `capture_path_not_resolvable`. It stays `null` for the witness and
751
+ size/depth arms, which genuinely have no path.
752
+
753
+ | Check kind | Emitted from | `passed` | `json_pointer` | `expected` | `actual` | Wire evidence |
754
+ | --- | --- | --- | --- | --- | --- | --- |
755
+ | `fixture_capture_unbuildable` | the `seed_*` / discovery step of the synthetic fixture-resolution phase, when a declared capture cannot be built from the pinned entity | always `false` | RFC 6901 form of the failing `path:`, else `null` | `a value at the declared selected-entity path, or a derivable witness` | `null` | yes — the VALIDATION itself carries the `get_products` or `comply_test_controller` exchange that produced the entity. This is a post-wire failure, so an explicit null would misreport it as no contact. When discovery paginated, the recorded exchange is the page that actually carried the pinned product; `page_count` and `cursors` in the resolution evidence disclose that there were more |
756
+ | `fixture_capture_unbuildable` | a synthetic `__fixture_capture__` step, when the protected funnel refuses the commit itself (freezability / extensibility — rules the pre-check does not model) | always `false` | `null` | `a capture the runner can deep-clone and freeze` | comma-joined refused key names | no — `duration_ms: 0`, no request/response; `task` is derived from the refused capture's own provenance |
757
+ | `fixture_capture_unbuildable` | a `runStoryboardStep` result when the prepared state's own disposition is `failed` | always `false` | `null` | `a fixture graph that resolved during preparation` | `preparation_failed` | no — `duration_ms: 0`; the preparation already happened |
758
+ | `protected_context_violation` | any of the five funnel commit sites, via one shared graded helper — prepared-capture hydration, both creative-probe context commits, the `not_applicable_if` availability gate, and the ordinary step's context patch | always `false` | `null` | `no write, delete or metadata change to the __adcp_fixture_ namespace` | comma-joined offending key names | depends on the site; the ordinary step carries its own exchange, the others are `duration_ms: 0` |
759
+ | `prepared_fixtures_authorization` | the run-start gate (`runStoryboard`), the per-pass gate, and every per-step gate | always `false` (an authorized lookup emits nothing) | `null` | `an authorized prepared state bound to this exact run scope` (run/pass) or `… to this exact step scope` (step) | one refusal code, below | no — `duration_ms: 0`; the gate precedes all seller contact including capability discovery |
760
+
761
+ `prepared_fixtures_authorization.actual` is a closed set of six:
762
+
763
+ | `actual` | Meaning |
764
+ | --- | --- |
765
+ | `authorizer_required` | reuse was requested with no `prepared_fixtures_authorizer`. Applies to DIRECT state as well as to an opaque reference. |
766
+ | `untrusted_state` | direct state that `prepareStoryboardFixtures` did not produce in this process (rehydrated, cloned, or a look-alike). |
767
+ | `contract_version_mismatch` | direct state whose `contract_version` is not this runner's. |
768
+ | `authorizer_denied` | the host authorizer refused. Also the answer for **every** pre-authorization failure on a reference, so the refusal cannot be used as an existence oracle. |
769
+ | `binding_mismatch` | authorized, but the binding does not match the current scope. `detail` names differing FIELDS only, never their values. |
770
+ | `unauthorized_metadata` | caller-supplied `context`, `context_provenance` or `response_derived_not_applicable_context_keys` carried a reserved or prototype-sensitive key. Screened before any seller contact. |
771
+
772
+ The lookup payload (`PreparedFixturesLookup`) carries `binding` (as
773
+ prepared), `current` (as observed now), `gate`, and — when applicable —
774
+ `step_id` and `reference`. Both bindings are separately deep-cloned on every
775
+ path, including refusals. Diffed binding fields are `storyboard_id`,
776
+ `storyboard_digest`, `agent_urls`, `agent_route_fingerprint`,
777
+ `schema_artifact`, `contract_version`, `account_scope`, `brand_scope`,
778
+ `sandbox`, `principal` and `credential_fingerprint`. `run_id`,
779
+ `step_credential_fingerprint`, `step_auth_kind` and `request_account_scope`
780
+ are reported but NOT diffed — they are per-step or per-preparation by
781
+ nature, and a diffed per-step field would refuse every step.
782
+
783
+ Two notes a renderer needs:
784
+
785
+ - The refusal **sentence** is uniform for every reference-shaped lookup —
786
+ `prepared fixture state was refused by the configured authorizer`. The
787
+ host's own `detail` is deliberately not echoed there, because a message
788
+ that varies with whether the reference resolved is an existence oracle.
789
+ Direct state keeps its specific text; that caller already holds the object.
790
+ - `unauthorized_metadata` is also the spelling of one
791
+ `ProtectedContextViolationCode`. The two never collide in output:
792
+ `protected_context_violation` puts offending KEY names in `actual`, never
793
+ a code.
794
+
795
+ **Skip semantics changed.** `fixture_unsatisfied` is now a canonical
796
+ `RunnerSkipReason`, so an unsatisfied prepared state reports
797
+ `skip_reason: fixture_unsatisfied` **and** `skip.reason: fixture_unsatisfied`.
798
+ It was previously a detailed reason mapped down to `not_applicable`, which
799
+ bucketed a fixture coverage gap with genuine applicability gates — the exact
800
+ distinction the contract's reason list exists to preserve. Both of the
801
+ runner's unsatisfied paths now spell it the same way.
802
+
803
+ The `fixture_unsatisfied` **detail** is the contract's exact form —
804
+ `fixture_unsatisfied: <fixture_type>:<handle>, …`, with pricing options as
805
+ `pricing_option:<product_handle>/<handle>` — and names every unsatisfied
806
+ fixture, not just the first.
807
+
808
+ ## Downgrade behavior on an older runner
809
+
810
+ Author the **declaration**, not just the validator name.
811
+
812
+ A runner without this contract rejects
813
+ `fixture_resolution.products[].context_outputs` and the
814
+ `collection_selection_satisfies` operator at **load time** — fail-closed.
815
+
816
+ A new *validator name* alone is not safe: unknown check kinds grade
817
+ `passed: true, not_applicable: true` by forward-compatibility contract, so a
818
+ storyboard carrying only `collection_set_equals_context` would be silently
819
+ undergraded by an older runner rather than failing.
820
+
821
+ ---
822
+
823
+ ## What this does not solve
824
+
825
+ Stated plainly, because a foundation that over-claims is worse than one with
826
+ documented edges:
827
+
828
+ - **Enclosing `phase.skip_if` in standalone execution.** `runStoryboardStep`
829
+ still executes a named step without evaluating its phase condition.
830
+ Preparation parity is not conditional-flow parity; restoring fixture
831
+ identities does not fix phase orchestration.
832
+ - **Two children needing two distinct pricing options on one product.**
833
+ Parent/price coherence is an existence check and deliberately
834
+ claim-blind; if the chosen parent carries only one qualifying option, the
835
+ second child strands with a visible diagnostic rather than reselecting.
836
+ - **Native feature-qualified offer binding for brief-only or empty-catalog
837
+ declarations.** Preparation pins entities that exist in a catalog. A
838
+ declaration-driven route is separate, WG-unratified work.
839
+ - **Ownership and host authorization.** The carriage relation is a declared
840
+ wire relationship only.
841
+ - **Reporting obligations.** Top-K selection from an unobserved population,
842
+ strict ISO `committed_at` filtering, per-metric measurability, and
843
+ absent-ledger current-catalog fallback are downstream source concerns.
844
+ - **Caller branding.** Request enrichment still uses `options.brand` /
845
+ `brand_manifest`, defaulting to `test.example`. `comply()` has one
846
+ run-level brand; heterogeneous suites need explicit caller orchestration.
847
+ Brand authority is never derived from captured seller metadata.
848
+ - **Hosted accounting.** How a raw discovery failure is classified by a
849
+ hosted disposition layer is an accounting-owner question; this change
850
+ alters no accounting or floors.