@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.
- package/AGENTS.md +2 -0
- package/dist/lib/adapters/version/3.1/assignments.d.mts +12 -0
- package/dist/lib/adapters/version/3.1/assignments.d.ts +12 -0
- package/dist/lib/adapters/version/3.1/assignments.js +67 -0
- package/dist/lib/adapters/version/3.1/assignments.mjs +43 -0
- package/dist/lib/adapters/version/3.1/bidding.d.mts +3 -0
- package/dist/lib/adapters/version/3.1/bidding.d.ts +3 -0
- package/dist/lib/adapters/version/3.1/bidding.js +115 -0
- package/dist/lib/adapters/version/3.1/bidding.mjs +90 -0
- package/dist/lib/adapters/version/3.1/delivery.d.mts +7 -0
- package/dist/lib/adapters/version/3.1/delivery.d.ts +7 -0
- package/dist/lib/adapters/version/3.1/delivery.js +78 -0
- package/dist/lib/adapters/version/3.1/delivery.mjs +54 -0
- package/dist/lib/adapters/version/3.1/index.d.mts +3 -0
- package/dist/lib/adapters/version/3.1/index.d.ts +3 -0
- package/dist/lib/adapters/version/3.1/index.js +36 -0
- package/dist/lib/adapters/version/3.1/index.mjs +12 -0
- package/dist/lib/adapters/version/index.d.mts +59 -7
- package/dist/lib/adapters/version/index.d.ts +59 -7
- package/dist/lib/adapters/version/index.js +51 -2
- package/dist/lib/adapters/version/index.mjs +49 -1
- package/dist/lib/adapters/version/negotiate.d.mts +71 -0
- package/dist/lib/adapters/version/negotiate.d.ts +71 -0
- package/dist/lib/adapters/version/negotiate.js +101 -0
- package/dist/lib/adapters/version/negotiate.mjs +73 -0
- package/dist/lib/adapters/version/schema-projection.d.mts +33 -0
- package/dist/lib/adapters/version/schema-projection.d.ts +33 -0
- package/dist/lib/adapters/version/schema-projection.js +294 -0
- package/dist/lib/adapters/version/schema-projection.mjs +269 -0
- package/dist/lib/adapters/version/translate.d.mts +122 -0
- package/dist/lib/adapters/version/translate.d.ts +122 -0
- package/dist/lib/adapters/version/translate.js +169 -0
- package/dist/lib/adapters/version/translate.mjs +141 -0
- package/dist/lib/adapters/version/types.d.mts +51 -10
- package/dist/lib/adapters/version/types.d.ts +51 -10
- package/dist/lib/core/AgentClient.d.mts +1 -1
- package/dist/lib/core/AgentClient.d.ts +1 -1
- package/dist/lib/core/AgentClient.js +17 -1
- package/dist/lib/core/AgentClient.mjs +17 -1
- package/dist/lib/core/ConversationTypes.d.mts +7 -0
- package/dist/lib/core/ConversationTypes.d.ts +7 -0
- package/dist/lib/core/SingleAgentClient.d.mts +82 -0
- package/dist/lib/core/SingleAgentClient.d.ts +82 -0
- package/dist/lib/core/SingleAgentClient.js +336 -15
- package/dist/lib/core/SingleAgentClient.mjs +350 -17
- package/dist/lib/core/TaskExecutor.d.mts +1 -1
- package/dist/lib/core/TaskExecutor.d.ts +1 -1
- package/dist/lib/core/TaskExecutor.js +182 -31
- package/dist/lib/core/TaskExecutor.mjs +183 -32
- package/dist/lib/core/account-reduction.d.mts +36 -0
- package/dist/lib/core/account-reduction.d.ts +36 -0
- package/dist/lib/core/account-reduction.js +127 -0
- package/dist/lib/core/account-reduction.mjs +98 -0
- package/dist/lib/core/buyer-account-registry.d.mts +52 -0
- package/dist/lib/core/buyer-account-registry.d.ts +52 -0
- package/dist/lib/core/buyer-account-registry.js +142 -6
- package/dist/lib/core/buyer-account-registry.mjs +142 -6
- package/dist/lib/errors/index.d.mts +34 -0
- package/dist/lib/errors/index.d.ts +34 -0
- package/dist/lib/errors/index.js +11 -0
- package/dist/lib/errors/index.mjs +10 -0
- package/dist/lib/index.d.mts +1 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/media-buy/action-contracts.js +4 -4
- package/dist/lib/media-buy/action-contracts.mjs +4 -4
- package/dist/lib/media-buy/action-types.d.mts +3 -2
- package/dist/lib/media-buy/action-types.d.ts +3 -2
- package/dist/lib/media-buy/compatibility.d.mts +1 -0
- package/dist/lib/media-buy/compatibility.d.ts +1 -0
- package/dist/lib/media-buy/compatibility.js +33 -10
- package/dist/lib/media-buy/compatibility.mjs +32 -14
- package/dist/lib/media-buy/update-fields.generated.d.mts +3 -2
- package/dist/lib/media-buy/update-fields.generated.d.ts +3 -2
- package/dist/lib/negotiation/buyer.js +11 -5
- package/dist/lib/negotiation/buyer.mjs +11 -5
- package/dist/lib/negotiation/types.d.mts +4 -2
- package/dist/lib/negotiation/types.d.ts +4 -2
- package/dist/lib/negotiation/verification.d.mts +15 -6
- package/dist/lib/negotiation/verification.d.ts +15 -6
- package/dist/lib/negotiation/verification.js +146 -27
- package/dist/lib/negotiation/verification.mjs +146 -28
- package/dist/lib/negotiation/version.d.mts +6 -0
- package/dist/lib/negotiation/version.d.ts +6 -0
- package/dist/lib/negotiation/version.js +60 -0
- package/dist/lib/negotiation/version.mjs +33 -0
- package/dist/lib/registry/types.generated.d.mts +1 -1
- package/dist/lib/registry/types.generated.d.ts +1 -1
- package/dist/lib/server/create-adcp-server.d.mts +21 -0
- package/dist/lib/server/create-adcp-server.d.ts +21 -0
- package/dist/lib/server/create-adcp-server.js +98 -16
- package/dist/lib/server/create-adcp-server.mjs +84 -2
- package/dist/lib/server/error-arm-tools.d.mts +10 -10
- package/dist/lib/server/error-arm-tools.d.ts +10 -10
- package/dist/lib/server/error-arm-tools.js +22 -58
- package/dist/lib/server/error-arm-tools.mjs +30 -65
- package/dist/lib/server/index.d.mts +1 -1
- package/dist/lib/server/index.d.ts +1 -1
- package/dist/lib/storage/interfaces.d.mts +2 -0
- package/dist/lib/storage/interfaces.d.ts +2 -0
- package/dist/lib/testing/compliance/comply.js +17 -0
- package/dist/lib/testing/compliance/comply.mjs +17 -0
- package/dist/lib/testing/index.d.mts +3 -0
- package/dist/lib/testing/index.d.ts +3 -0
- package/dist/lib/testing/index.js +44 -2
- package/dist/lib/testing/index.mjs +42 -0
- package/dist/lib/testing/storyboard/collection-selection.d.mts +259 -0
- package/dist/lib/testing/storyboard/collection-selection.d.ts +259 -0
- package/dist/lib/testing/storyboard/collection-selection.js +384 -0
- package/dist/lib/testing/storyboard/collection-selection.mjs +348 -0
- package/dist/lib/testing/storyboard/context.js +12 -6
- package/dist/lib/testing/storyboard/context.mjs +12 -6
- package/dist/lib/testing/storyboard/fixture-resolution.d.mts +39 -1
- package/dist/lib/testing/storyboard/fixture-resolution.d.ts +39 -1
- package/dist/lib/testing/storyboard/fixture-resolution.js +216 -12
- package/dist/lib/testing/storyboard/fixture-resolution.mjs +218 -12
- package/dist/lib/testing/storyboard/index.d.mts +9 -2
- package/dist/lib/testing/storyboard/index.d.ts +9 -2
- package/dist/lib/testing/storyboard/index.js +51 -2
- package/dist/lib/testing/storyboard/index.mjs +49 -1
- package/dist/lib/testing/storyboard/prepared-fixtures.d.mts +471 -0
- package/dist/lib/testing/storyboard/prepared-fixtures.d.ts +471 -0
- package/dist/lib/testing/storyboard/prepared-fixtures.js +434 -0
- package/dist/lib/testing/storyboard/prepared-fixtures.mjs +389 -0
- package/dist/lib/testing/storyboard/protected-context.d.mts +127 -0
- package/dist/lib/testing/storyboard/protected-context.d.ts +127 -0
- package/dist/lib/testing/storyboard/protected-context.js +301 -0
- package/dist/lib/testing/storyboard/protected-context.mjs +267 -0
- package/dist/lib/testing/storyboard/rejection-hints.js +13 -4
- package/dist/lib/testing/storyboard/rejection-hints.mjs +13 -4
- package/dist/lib/testing/storyboard/runner.d.mts +24 -0
- package/dist/lib/testing/storyboard/runner.d.ts +24 -0
- package/dist/lib/testing/storyboard/runner.js +1061 -105
- package/dist/lib/testing/storyboard/runner.mjs +1075 -106
- package/dist/lib/testing/storyboard/seeding.d.mts +15 -1
- package/dist/lib/testing/storyboard/seeding.d.ts +15 -1
- package/dist/lib/testing/storyboard/seeding.js +272 -12
- package/dist/lib/testing/storyboard/seeding.mjs +274 -13
- package/dist/lib/testing/storyboard/types.d.mts +207 -10
- package/dist/lib/testing/storyboard/types.d.ts +207 -10
- package/dist/lib/testing/storyboard/types.js +1 -1
- package/dist/lib/testing/storyboard/types.mjs +1 -1
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/testing/storyboard/validations.js +95 -0
- package/dist/lib/testing/storyboard/validations.mjs +95 -0
- package/dist/lib/validation/bundled-schema-store.js +5 -0
- package/dist/lib/validation/bundled-schema-store.mjs +4 -0
- package/dist/lib/validation/schema-loader.js +19 -2
- package/dist/lib/validation/schema-loader.mjs +22 -3
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.mjs +3 -3
- package/docs/TYPE-SUMMARY.md +2 -2
- package/docs/guides/COLLECTION-FIXTURE-FOUNDATION.md +850 -0
- package/docs/guides/PROPOSAL-TERMS-VERIFICATION.md +66 -0
- package/docs/llms.txt +4 -3
- package/docs/migration-14.x-rc-worksheet.md +4 -4
- 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.
|