@integraledger/agent-guard 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +15 -0
  4. package/README.md +154 -0
  5. package/dist/decision.d.ts +26 -0
  6. package/dist/decision.d.ts.map +1 -0
  7. package/dist/decision.js +28 -0
  8. package/dist/decision.js.map +1 -0
  9. package/dist/evaluate.d.ts +15 -0
  10. package/dist/evaluate.d.ts.map +1 -0
  11. package/dist/evaluate.js +112 -0
  12. package/dist/evaluate.js.map +1 -0
  13. package/dist/fetch.d.ts +61 -0
  14. package/dist/fetch.d.ts.map +1 -0
  15. package/dist/fetch.js +126 -0
  16. package/dist/fetch.js.map +1 -0
  17. package/dist/fingerprint.d.ts +14 -0
  18. package/dist/fingerprint.d.ts.map +1 -0
  19. package/dist/fingerprint.js +15 -0
  20. package/dist/fingerprint.js.map +1 -0
  21. package/dist/index.d.ts +15 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +15 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/log.d.ts +32 -0
  26. package/dist/log.d.ts.map +1 -0
  27. package/dist/log.js +8 -0
  28. package/dist/log.js.map +1 -0
  29. package/dist/mechanical.d.ts +30 -0
  30. package/dist/mechanical.d.ts.map +1 -0
  31. package/dist/mechanical.js +35 -0
  32. package/dist/mechanical.js.map +1 -0
  33. package/dist/policy.d.ts +36 -0
  34. package/dist/policy.d.ts.map +1 -0
  35. package/dist/policy.js +96 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/proposal-ack.d.ts +111 -0
  38. package/dist/proposal-ack.d.ts.map +1 -0
  39. package/dist/proposal-ack.js +114 -0
  40. package/dist/proposal-ack.js.map +1 -0
  41. package/dist/proposal-acp.d.ts +18 -0
  42. package/dist/proposal-acp.d.ts.map +1 -0
  43. package/dist/proposal-acp.js +72 -0
  44. package/dist/proposal-acp.js.map +1 -0
  45. package/dist/proposal-ap2.d.ts +121 -0
  46. package/dist/proposal-ap2.d.ts.map +1 -0
  47. package/dist/proposal-ap2.js +112 -0
  48. package/dist/proposal-ap2.js.map +1 -0
  49. package/dist/proposal-mpp.d.ts +44 -0
  50. package/dist/proposal-mpp.d.ts.map +1 -0
  51. package/dist/proposal-mpp.js +106 -0
  52. package/dist/proposal-mpp.js.map +1 -0
  53. package/dist/proposal-universal.d.ts +239 -0
  54. package/dist/proposal-universal.d.ts.map +1 -0
  55. package/dist/proposal-universal.js +470 -0
  56. package/dist/proposal-universal.js.map +1 -0
  57. package/dist/proposal.d.ts +33 -0
  58. package/dist/proposal.d.ts.map +1 -0
  59. package/dist/proposal.js +107 -0
  60. package/dist/proposal.js.map +1 -0
  61. package/dist/transact.d.ts +24 -0
  62. package/dist/transact.d.ts.map +1 -0
  63. package/dist/transact.js +11 -0
  64. package/dist/transact.js.map +1 -0
  65. package/package.json +81 -0
  66. package/src/decision.ts +54 -0
  67. package/src/evaluate.ts +176 -0
  68. package/src/fetch.ts +169 -0
  69. package/src/fingerprint.ts +27 -0
  70. package/src/index.ts +68 -0
  71. package/src/log.ts +34 -0
  72. package/src/mechanical.ts +71 -0
  73. package/src/policy.ts +135 -0
  74. package/src/proposal-ack.ts +216 -0
  75. package/src/proposal-acp.ts +89 -0
  76. package/src/proposal-ap2.ts +195 -0
  77. package/src/proposal-mpp.ts +125 -0
  78. package/src/proposal-universal.ts +671 -0
  79. package/src/proposal.ts +170 -0
  80. package/src/transact.ts +34 -0
@@ -0,0 +1,671 @@
1
+ import {
2
+ type CarrierClass,
3
+ decodeDeclaredRead,
4
+ type LegalContextRef,
5
+ type PlacementContainer,
6
+ type PlacementEncoding,
7
+ type PlacementManifest,
8
+ type ProtocolId,
9
+ readAtPath,
10
+ readDeclaredPaths,
11
+ readFromContainer,
12
+ } from "@integraledger/lcp-binding-core";
13
+ import {
14
+ type PlacementDeployment,
15
+ placementFor,
16
+ } from "@integraledger/lcp-placements";
17
+ import {
18
+ type GateProposal,
19
+ type ProposalContext,
20
+ parseProposalFromChallenge,
21
+ } from "./proposal.js";
22
+ import { parseProposalFromAcpCheckout } from "./proposal-acp.js";
23
+
24
+ // ---------------------------------------------------------------------------------------------------------
25
+ // Structural predicates. Every read goes through `readAtPath`, never through `doc.x` or `doc["x"]`, so the
26
+ // own-property and array-traversal rules a hostile wire is read under are binding-core's audited ones rather
27
+ // than a second set invented here. A prototype key answering as though it were a declared field is exactly
28
+ // how a detector gets talked into naming the wrong protocol.
29
+ // ---------------------------------------------------------------------------------------------------------
30
+
31
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function isStringAt(doc: unknown, path: string): boolean {
36
+ return typeof readAtPath(doc, path) === "string";
37
+ }
38
+
39
+ function isArrayAt(doc: unknown, path: string): boolean {
40
+ return Array.isArray(readAtPath(doc, path));
41
+ }
42
+
43
+ function stringAtIsOneOf(
44
+ doc: unknown,
45
+ path: string,
46
+ permitted: readonly string[],
47
+ ): boolean {
48
+ const value = readAtPath(doc, path);
49
+ // `some` rather than a `typeof` guard plus `includes`: the guard would be a branch no input can take, since
50
+ // a list of strings never contains a non-string, and a branch nothing reaches is one no test can constrain.
51
+ return permitted.some((token) => token === value);
52
+ }
53
+
54
+ /**
55
+ * ACP's `CheckoutSession.status` — the closed eleven-value enum, and the discriminant that separates an ACP
56
+ * session from every other checkout-shaped document in the set.
57
+ *
58
+ * DERIVED, NOT AUTHORED — from the live ACP schema, which `placement-acp` enumerated protocol-side in a
59
+ * write condition it no longer declares (checked 2026-07-30).
60
+ *
61
+ * ⚠️ THIS COPY NO LONGER HAS A DRIFT GUARD. It was pinned equal to the manifest's write condition through
62
+ * the registry, but the protocol line declares no `writeCondition` on any ACP manifest — its `readAlso` aliases
63
+ * carry `{path, encoding}` only — so the enum is not published anywhere to pin against. The pin test was
64
+ * deleted rather than loosened (an assertion over an absent field guards nothing). Re-derive this list
65
+ * against the live ACP schema when touching it; the discriminant tests only prove it matches our fixtures.
66
+ */
67
+ export const ACP_SESSION_STATUS: readonly string[] = [
68
+ "incomplete",
69
+ "not_ready_for_payment",
70
+ "requires_escalation",
71
+ "authentication_required",
72
+ "ready_for_payment",
73
+ "pending_approval",
74
+ "complete_in_progress",
75
+ "completed",
76
+ "canceled",
77
+ "in_progress",
78
+ "expired",
79
+ ];
80
+
81
+ /**
82
+ * The two Verifiable Intent Autonomous-mode credential types, derived from Mastercard's own open-mandate
83
+ * definitions. Formerly pinned against `placement-mastercard-vi`'s write condition; that placement is now
84
+ * declaration-only (LCP v1.38 §C.7 — an unregistered constraint type gets the WHOLE mandate rejected by a
85
+ * stock verifier), so it declares no write condition and there is nothing to pin against. Same standing as
86
+ * {@link ACP_SESSION_STATUS}: hand-kept, re-derive against the host when touching it.
87
+ */
88
+ export const VI_OPEN_MANDATE_VCT: readonly string[] = [
89
+ "mandate.checkout.open.1",
90
+ "mandate.payment.open.1",
91
+ ];
92
+
93
+ /** The two AP2 v0.2 mandate DataPart keys. An envelope carrying either is carrying an AP2 mandate. */
94
+ const AP2_MANDATE_DATA_KEYS: readonly string[] = [
95
+ "ap2.mandates.CheckoutMandateSdJwt",
96
+ "ap2.mandates.PaymentMandateSdJwt",
97
+ ];
98
+
99
+ /** Visa TAP's two agent-recognition signature tags, as they appear quoted in an RFC 9421 `Signature-Input`. */
100
+ const TAP_SIGNATURE_TAGS: readonly string[] = [
101
+ "agent-browser-auth",
102
+ "agent-payer-auth",
103
+ ];
104
+
105
+ /** An A2A `Task`: `id` and `status` are the two REQUIRED members (a2a.proto lines 178-190). */
106
+ function isA2aTask(doc: unknown): boolean {
107
+ return isStringAt(doc, "id") && isStringAt(doc, "status.state");
108
+ }
109
+
110
+ /** An A2A `Message`: `messageId`, `role` and `parts` are its three REQUIRED members (a2a.proto 267-280). */
111
+ function isA2aMessage(doc: unknown): boolean {
112
+ return (
113
+ isStringAt(doc, "messageId") &&
114
+ isStringAt(doc, "role") &&
115
+ isArrayAt(doc, "parts")
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Does this document carry an AP2 mandate DataPart?
121
+ *
122
+ * Reads `parts` itself rather than trusting a caller to have checked it, because the AP2 rule asks this
123
+ * question FIRST — the mandate part is AP2's own half of the discriminant, and the A2A envelope check is a
124
+ * second, weaker fact about it.
125
+ */
126
+ function carriesAp2Mandate(doc: unknown): boolean {
127
+ const parts = readAtPath(doc, "parts");
128
+ if (!Array.isArray(parts)) return false;
129
+ return parts.some((_, index) => {
130
+ const data = readAtPath(doc, `parts.${index}.data`);
131
+ return (
132
+ isRecord(data) &&
133
+ AP2_MANDATE_DATA_KEYS.some((key) => Object.hasOwn(data, key))
134
+ );
135
+ });
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------------------------------------
139
+ // The discriminant table
140
+ // ---------------------------------------------------------------------------------------------------------
141
+
142
+ /**
143
+ * How ONE protocol's document is recognized — or the recorded fact that it cannot be.
144
+ *
145
+ * A keyed union rather than an optional `matches`, because "no rule" and "a rule that never fires" are
146
+ * different claims and only one of them is honest about a protocol whose document carries nothing to
147
+ * recognize. An `undiscriminable` row is a finding with a citation, not a hole: it says the live specification
148
+ * was read and no identifying member exists, which is the answer a future unit needs in order not to redo the
149
+ * reading, and it keeps the table TOTAL over the placement registry so a newly registered protocol cannot slip
150
+ * in unnoticed.
151
+ *
152
+ * `cite` is data rather than a comment because a claim about somebody else's protocol is exactly the kind that
153
+ * gets stale, and an audit trail that only a reader of the source can see is not one a deployment can act on.
154
+ */
155
+ export type ProtocolDiscriminant =
156
+ | {
157
+ readonly protocol: ProtocolId;
158
+ readonly kind: "structural";
159
+ /** Does this document belong to `protocol`? TOTAL — never throws, on any shape a wire can present. */
160
+ readonly matches: (doc: unknown) => boolean;
161
+ /** The host protocol's own source the rule was read from, and when. */
162
+ readonly cite: string;
163
+ }
164
+ | {
165
+ readonly protocol: ProtocolId;
166
+ readonly kind: "undiscriminable";
167
+ /** Why no rule exists, and where the protocol's identity actually lives instead. */
168
+ readonly reason: string;
169
+ readonly cite: string;
170
+ };
171
+
172
+ /**
173
+ * Every supported protocol's document discriminant, one row each.
174
+ *
175
+ * DETECTION IS BY DISCRIMINANT, NEVER BY "does the field I want happen to be present": a document is x402
176
+ * because it carries `x402Version`, not because it has an atrHash somewhere. Detecting on the LCP field would
177
+ * make every protocol look alike the moment it carried a reference — which is exactly the situation this table
178
+ * exists to disambiguate.
179
+ *
180
+ * EVERY RULE IS POSITIVE. No row says "and not the other protocol's marker". Documents CAN satisfy two rows,
181
+ * and where they do the answer is AMBIGUITY, which {@link parseProposalUniversal} refuses. A negative term
182
+ * would convert that refusal into a silent choice, and a wrong negative term (one protocol adding a field
183
+ * another already had) converts it into a silent WRONG choice. Refusing is never the wrong answer; guessing
184
+ * sometimes is.
185
+ *
186
+ * THE TWO OVERLAPS ARE NOT THE SAME STRENGTH, and the difference is load-bearing:
187
+ *
188
+ * - ACP/UCP is CONTINGENT. A UCP checkout response that also carries `currency`, `totals` and `line_items`
189
+ * satisfies both rows; one that does not carries only `ucp`, `id`, `status` and `links` and is
190
+ * unambiguously UCP. Either protocol's documents remain individually reachable.
191
+ * - AP2/A2A is TOTAL. `ap2` matches `carriesAp2Mandate(doc) && isA2aMessage(doc)`, and `a2a` matches
192
+ * `isA2aTask(doc) || isA2aMessage(doc)`, so the `ap2` predicate is a strict logical SUBSET of the `a2a`
193
+ * one: every document the AP2 row fires on fires the A2A row too, without exception and by construction.
194
+ * So NO AP2 document is reachable through {@link parseProposalUniversal} — the ambiguity refusal always
195
+ * triggers first, and registering an `ap2` entry in {@link PROPOSAL_PARSERS} would not make one parseable.
196
+ * A caller holding an AP2 envelope names `ap2` and calls its parser directly. That is a consequence of AP2
197
+ * defining no transport of its own (v0.2 rides A2A), not a defect to engineer away: a negative term on the
198
+ * A2A row would be this package asserting that a valid A2A message is not one, which is A2A's call to
199
+ * make rather than ours — the host protocol defines its own documents.
200
+ *
201
+ * ORDERED as `KNOWN_PROTOCOL_IDS` orders the closed set, so `matchProtocols` reports in a stable order rather
202
+ * than in the order units happened to land.
203
+ */
204
+ export const PROTOCOL_DISCRIMINANTS: readonly ProtocolDiscriminant[] = [
205
+ {
206
+ protocol: "x402",
207
+ kind: "structural",
208
+ matches: (doc) =>
209
+ typeof readAtPath(doc, "x402Version") === "number" &&
210
+ isArrayAt(doc, "accepts"),
211
+ cite: "x402 v2 PaymentRequired — `x402Version` plus the `accepts` requirement array (coinbase/x402 specs/x402-specification-v2.md §5.1.2, read 2026-07-30)",
212
+ },
213
+ {
214
+ protocol: "mpp",
215
+ kind: "undiscriminable",
216
+ reason:
217
+ "The document `placement-mpp` operates on is the DECODED `request` auth-param body, whose members are `amount` and `currency` (both REQUIRED strings, §5.1.1) plus optional `recipient`, `description`, `externalId` and `methodDetails` (§5.1.2). Not one of them names MPP, and an amount/currency pair is the shape of almost every payment document there is. MPP's identity lives one layer OUT, in the `WWW-Authenticate: Payment` challenge's auth-params (`realm`, `method`, `id`, `request`, `expires`, `digest`, `opaque`) — which is not the document the placement reads, so a caller holding a decoded request body must name `mpp` itself. Note `expires` is an auth-param ONLY: §5.1.2 states that expiry is conveyed in the challenge and that request objects MUST NOT duplicate it, so a body carrying one is malformed rather than discriminating.",
218
+ cite: "MPP charge intent draft-payment-intent-charge-00 §5.1.1 Table 2 (required: amount, currency) and §5.1.2 Table 3 (optional: recipient, description, externalId, methodDetails) (mpp.dev/intents/charge, read 2026-07-30)",
219
+ },
220
+ {
221
+ protocol: "ap2",
222
+ kind: "structural",
223
+ // AP2's own half first, A2A's second: the mandate DataPart is what makes this AP2 rather than any other
224
+ // A2A message, and asking it first is also what keeps the `parts`-is-an-array guard a reachable branch
225
+ // instead of one another predicate has already made true.
226
+ //
227
+ // This conjunction is a strict subset of the `a2a` row's disjunction, so the two ALWAYS co-fire — see the
228
+ // table docblock. `ap2` is therefore a detect-and-name row, never a dispatchable one.
229
+ matches: (doc) => carriesAp2Mandate(doc) && isA2aMessage(doc),
230
+ cite: "AP2 v0.2 defines no transport; its reference samples carry mandates as A2A DataParts keyed `ap2.mandates.CheckoutMandateSdJwt` / `ap2.mandates.PaymentMandateSdJwt`, and the reference rides the A2A envelope's `metadata` beside them (placement-ap2 specification gate discharged 2026-07-30)",
231
+ },
232
+ {
233
+ protocol: "ack",
234
+ kind: "structural",
235
+ matches: (doc) => {
236
+ const types = readAtPath(doc, "type");
237
+ return (
238
+ Array.isArray(types) &&
239
+ types.includes("PaymentReceiptCredential") &&
240
+ isRecord(readAtPath(doc, "credentialSubject"))
241
+ );
242
+ },
243
+ cite: 'ACK-Pay `createPaymentReceipt` mints a W3C credential typed ["VerifiableCredential", "PaymentReceiptCredential"] with a `credentialSubject` (agentcommercekit/ack packages/ack-pay/src/create-payment-receipt.ts + packages/vc/src/create-credential.ts, read 2026-07-30)',
244
+ },
245
+ {
246
+ protocol: "acp",
247
+ kind: "structural",
248
+ matches: (doc) =>
249
+ stringAtIsOneOf(doc, "status", ACP_SESSION_STATUS) &&
250
+ isStringAt(doc, "currency") &&
251
+ isArrayAt(doc, "totals") &&
252
+ isArrayAt(doc, "line_items"),
253
+ cite: "ACP agentic checkout (stable 2026-04-17) — `status` is REQUIRED on `CheckoutSessionBase` and is a closed eleven-value enum absent from all three request schemas; `currency`, `totals` and `line_items` are its siblings (enum enumerated live 2026-07-30, see placement-acp's write condition)",
254
+ },
255
+ {
256
+ protocol: "ucp",
257
+ kind: "structural",
258
+ matches: (doc) =>
259
+ isRecord(readAtPath(doc, "ucp")) &&
260
+ isStringAt(doc, "id") &&
261
+ isStringAt(doc, "status") &&
262
+ isArrayAt(doc, "links"),
263
+ cite: "UCP checkout response — `ucp` (the UCP metadata object), `id`, `status` and `links` are all REQUIRED, and `ucp` is the member no other protocol in the set carries (ucp.dev/latest/specification/checkout, read 2026-07-30)",
264
+ },
265
+ {
266
+ protocol: "visa-tap",
267
+ kind: "structural",
268
+ matches: (doc) => {
269
+ const signatureInput = readFromContainer(
270
+ doc,
271
+ { kind: "header-map" },
272
+ "headers.signature-input",
273
+ );
274
+ return (
275
+ typeof signatureInput === "string" &&
276
+ TAP_SIGNATURE_TAGS.some((tag) => signatureInput.includes(`"${tag}"`))
277
+ );
278
+ },
279
+ cite: "Visa TAP agent recognition signature — an RFC 9421 `Signature-Input` whose `tag` parameter is `agent-browser-auth` or `agent-payer-auth` (placement-visa-tap specification gate discharged 2026-07-30)",
280
+ },
281
+ {
282
+ protocol: "mastercard-vi",
283
+ kind: "structural",
284
+ matches: (doc) =>
285
+ stringAtIsOneOf(doc, "vct", VI_OPEN_MANDATE_VCT) &&
286
+ isArrayAt(doc, "constraints"),
287
+ cite: "Verifiable Intent — `constraints` appears in Autonomous-mode open mandates only, whose `vct` is `mandate.checkout.open.1` or `mandate.payment.open.1` (placement-mastercard-vi specification gate discharged 2026-07-30)",
288
+ },
289
+ {
290
+ protocol: "a2a",
291
+ kind: "structural",
292
+ matches: (doc) => isA2aTask(doc) || isA2aMessage(doc),
293
+ cite: "A2A — `Task` REQUIRES `id` and `status`, `Message` REQUIRES `messageId`, `role` and `parts`; both carry the free-form `metadata` map this placement writes (a2aproject/A2A specification/a2a.proto @ main, read 2026-07-30)",
294
+ },
295
+ ];
296
+
297
+ /**
298
+ * Every protocol whose discriminant this document satisfies, in the table's order.
299
+ *
300
+ * ALL matches, never the first. A first-match detector cannot tell a document that belongs to one protocol
301
+ * from a document that belongs to two, and the second case is the one that costs a buyer money: an AP2
302
+ * envelope IS an A2A message, so both rows fire on it, and answering "a2a" because it came first would be a
303
+ * guess wearing an answer's clothes.
304
+ */
305
+ export function matchProtocols(wire: unknown): readonly ProtocolId[] {
306
+ const matched: ProtocolId[] = [];
307
+ for (const row of PROTOCOL_DISCRIMINANTS) {
308
+ if (row.kind === "structural" && row.matches(wire))
309
+ matched.push(row.protocol);
310
+ }
311
+ return matched;
312
+ }
313
+
314
+ /**
315
+ * Identify which commerce protocol a wire document belongs to, structurally.
316
+ *
317
+ * Returns `undefined` unless EXACTLY ONE discriminant fires — so both "nothing matched" and "several matched"
318
+ * answer `undefined`, because a single-valued return cannot honestly distinguish them and inventing a
319
+ * preference is the failure this function exists to prevent. A caller that needs to tell the two apart calls
320
+ * {@link matchProtocols} and reads the length; {@link parseProposalUniversal} does exactly that, and refuses
321
+ * each case with its own message.
322
+ *
323
+ * This function never guesses and never throws.
324
+ */
325
+ export function detectProtocol(wire: unknown): ProtocolId | undefined {
326
+ const matched = matchProtocols(wire);
327
+ return matched.length === 1 ? matched[0] : undefined;
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------------------------------------
331
+ // The universal LCP read — dispatched through the placement registry
332
+ // ---------------------------------------------------------------------------------------------------------
333
+
334
+ /**
335
+ * The terms URL a document advertises at the path its manifest declares — or the reason there is no answer,
336
+ * stated rather than folded into an `undefined`.
337
+ *
338
+ * A UNION, because `string | undefined` conflated three facts and only two of them are absences. The third is
339
+ * x402 today: `PlacementManifest.termsUrlField` is SINGULAR, and x402's names a path INSIDE the
340
+ * `extensions.legalContext.info` carrier — while the challenge LCP v1.38 §C.4 illustrates carries both the
341
+ * reference and the URL in `accepts[].extra` instead, which the manifest declares as a `readAlso` alias. Read
342
+ * such a challenge and the hash answers from the alias while the declared terms path holds nothing. That is
343
+ * not "this seller advertised no terms"; it is "the manifest says nothing about where a terms URL rides on the
344
+ * carrier that answered". Returning `undefined` there would assert the seller's silence — and
345
+ * `parseProposalFromChallenge`, reading the same bytes in the same package, would contradict it.
346
+ *
347
+ * The gap is owed back to the manifest: a per-alias terms-URL declaration is the fix and it is a protocol-repo
348
+ * change. Until it lands this type REPORTS the gap, because a reader that cannot tell two facts apart must say
349
+ * which one it cannot tell.
350
+ */
351
+ export type AdvertisedTermsUrl =
352
+ /** Read from the field the manifest declares. */
353
+ | { readonly kind: "read"; readonly url: string }
354
+ /** The manifest declares no terms-URL field at all: the protocol has no room for one. */
355
+ | { readonly kind: "no-field-declared" }
356
+ /** The manifest declares one, its declaration reaches the answering carrier, and the document leaves it empty. */
357
+ | { readonly kind: "declared-field-empty"; readonly field: string }
358
+ /**
359
+ * The manifest's terms-URL path lies INSIDE the canonical carrier's own object, and the reference answered
360
+ * from a declared alias instead — so the declaration never reached the carrier this document used. Whether
361
+ * the document advertises a terms URL is UNKNOWN to this reader, not answered.
362
+ */
363
+ | {
364
+ readonly kind: "undeclared-at-answering-carrier";
365
+ readonly field: string;
366
+ readonly answeredAt: string;
367
+ };
368
+
369
+ /** What a protocol document advertises about the terms governing it. */
370
+ export type AdvertisedTerms = {
371
+ /** The protocol whose manifest the carriers were read through. */
372
+ readonly protocol: ProtocolId;
373
+ /** The advertised ATR hash, reconciled across every integrity-bearing carrier the manifest declares. */
374
+ readonly advertisedAtrHash: `0x${string}`;
375
+ /** What the document says about where its terms live — read, absent, or not answerable from here. */
376
+ readonly legalContextUrl: AdvertisedTermsUrl;
377
+ };
378
+
379
+ /** One carrier slot a manifest declares, flattened so the canonical field and its aliases read alike. */
380
+ type CarrierSlot = {
381
+ readonly path: string;
382
+ readonly container: PlacementContainer;
383
+ readonly encoding: PlacementEncoding;
384
+ readonly carrierClass: CarrierClass;
385
+ readonly carrierTypes: readonly LegalContextRef["type"][];
386
+ };
387
+
388
+ /**
389
+ * The canonical field plus every declared alias, each flattened into a one-field manifest of its own.
390
+ *
391
+ * A SLOT IS A MANIFEST, so the actual reading is `readDeclaredPaths` — the same function, once per declared
392
+ * path, rather than a second decoder that has to be kept honest by review. Only the INHERITANCE rules are
393
+ * restated (an alias takes the manifest's container and encoding unless it declares its own, and is
394
+ * `integrity` unless it says otherwise), because binding-core resolves those inside the first-hit-wins loop a
395
+ * buyer cannot use. Everything downstream of a resolved slot — the `bare-value` type coming from
396
+ * `carrierTypes[0]`, the wrapping into the §8.1 codec, the corrupt-value throw — stays where it was written.
397
+ *
398
+ * A declared `bareType` becomes the slot's WHOLE `carrierTypes`, which is the same thing said in the shape
399
+ * this call takes: a bare value carries no type tag, so its type is whatever its one-field contract fixes.
400
+ */
401
+ function carrierSlots(manifest: PlacementManifest): readonly CarrierSlot[] {
402
+ const canonical: CarrierSlot = {
403
+ path: manifest.field,
404
+ container: manifest.container,
405
+ encoding: manifest.encoding,
406
+ carrierClass: "integrity",
407
+ carrierTypes: manifest.carrierTypes,
408
+ };
409
+ return [
410
+ canonical,
411
+ ...(manifest.readAlso ?? []).map(
412
+ (alias): CarrierSlot => ({
413
+ path: alias.path,
414
+ container: alias.container ?? manifest.container,
415
+ encoding: alias.encoding ?? manifest.encoding,
416
+ carrierClass: alias.carrierClass ?? "integrity",
417
+ carrierTypes:
418
+ alias.bareType === undefined
419
+ ? manifest.carrierTypes
420
+ : [alias.bareType],
421
+ }),
422
+ ),
423
+ ];
424
+ }
425
+
426
+ /**
427
+ * The reference each declared INTEGRITY carrier actually holds, with the path it was read from.
428
+ *
429
+ * Discovery-class slots are skipped before they are read, not after: UCP's `links[type=terms_of_service].url`
430
+ * locates the terms without attesting to them — LCP v1.38 §C.3 files UCP's `links` under "discovery without
431
+ * integrity" and says a standing policy page "is not a per-transaction terms record and carries no hash",
432
+ * which is exactly why one cannot stand in for the other. Reading
433
+ * it and discarding it later would also mean decoding a URL under the capability field's `sha256` contract,
434
+ * which is a corrupt carrier and throws.
435
+ */
436
+ function integrityHits(
437
+ doc: unknown,
438
+ manifest: PlacementManifest,
439
+ ): readonly { readonly path: string; readonly ref: LegalContextRef }[] {
440
+ const hits: { path: string; ref: LegalContextRef }[] = [];
441
+ for (const slot of carrierSlots(manifest)) {
442
+ if (slot.carrierClass !== "integrity") continue;
443
+ const read = readDeclaredPaths(doc, {
444
+ field: slot.path,
445
+ container: slot.container,
446
+ encoding: slot.encoding,
447
+ carrierTypes: slot.carrierTypes,
448
+ });
449
+ if (read === undefined) continue;
450
+ const ref = decodeDeclaredRead(read);
451
+ if (ref === undefined) continue;
452
+ hits.push({ path: slot.path, ref });
453
+ }
454
+ return hits;
455
+ }
456
+
457
+ /** Two references are the same reference iff type and value agree — hex compared case-insensitively. */
458
+ function carrierKey(ref: LegalContextRef): string {
459
+ return ref.type === "sha256"
460
+ ? `sha256:${ref.value.toLowerCase()}`
461
+ : `${ref.type}:${ref.value}`;
462
+ }
463
+
464
+ /**
465
+ * Read what ANY supported protocol document advertises: its ATR hash, and its terms URL where the protocol
466
+ * has room for one.
467
+ *
468
+ * UNIVERSAL BY CONSTRUCTION. The carriers are not listed here — they are read out of the protocol's own
469
+ * `PlacementManifest` through `@integraledger/lcp-placements`, so a protocol this function supports is precisely
470
+ * one the build can also place a reference INTO, and adding a protocol product-side is nothing at all. There
471
+ * is no second place protocols are listed and no path by which a reader and a writer can drift.
472
+ *
473
+ * THE PROTOCOL IS AN ARGUMENT, NOT A DETECTION. Detection is the only part of this seam that can be wrong, so
474
+ * a caller that knows its protocol — which is the ordinary case, since a buyer agent knows which counterparty
475
+ * it dialled — never pays for it. {@link detectProtocol} is available for the caller that genuinely does not.
476
+ *
477
+ * FAIL-FAST, four ways, all loud:
478
+ *
479
+ * - a protocol with no registered placement (`mcp` is the only one, and LCP v1.38 §C.9 makes that terminal:
480
+ * it describes an LCP-aware MCP *server*, which has no document field for a reference to ride in);
481
+ * - a document advertising nothing at any declared carrier;
482
+ * - DISAGREEMENT between two declared carriers. This is the generalization of the x402 two-carrier rule to
483
+ * every protocol in the set, and it is deliberately stricter than the placement adapter's own `extract`,
484
+ * which answers with the canonical field and says nothing (`readDeclaredPaths` returns the first hit, not
485
+ * the set). A placement is structural and does not adjudicate a host's document; a BUYER must, because two
486
+ * different values on one document would let a seller advertise different terms to different readers of it
487
+ * and then disown whichever one it lost by. Preference is not an answer here — refusal is;
488
+ * - a reference that is not a `sha256` carrier. `carrierTypes` permits `url` on several manifests and that is
489
+ * correct for a placement, but the gate compares the advertised value against a RECOMPUTED record hash, and
490
+ * nothing but a hash can be compared to a hash.
491
+ *
492
+ * `deployment` is required only for a protocol whose placement is namespaced — Mastercard VI, whose constraint
493
+ * type is minted under the deployment's own reverse-domain namespace and has no default. Omitting it there
494
+ * throws from the registry rather than answering about some invented namespace.
495
+ */
496
+ export function readAdvertisedTerms(
497
+ protocol: ProtocolId,
498
+ wire: unknown,
499
+ deployment?: PlacementDeployment,
500
+ ): AdvertisedTerms {
501
+ const placement = placementFor(protocol, deployment);
502
+ if (placement === undefined)
503
+ throw new Error(
504
+ `no placement is registered for ${protocol} — this build cannot read a reference out of its documents`,
505
+ );
506
+ const manifest = placement.manifest;
507
+
508
+ const hits = integrityHits(wire, manifest);
509
+ const sole = hits[0];
510
+ if (sole === undefined) {
511
+ const looked = carrierSlots(manifest)
512
+ .filter((slot) => slot.carrierClass === "integrity")
513
+ .map((slot) => slot.path)
514
+ .join(", ");
515
+ throw new Error(
516
+ `${protocol} document advertises no LCP reference at any declared integrity carrier (${looked})`,
517
+ );
518
+ }
519
+
520
+ const distinct = new Set(hits.map((hit) => carrierKey(hit.ref)));
521
+ if (distinct.size > 1)
522
+ throw new Error(
523
+ `${protocol} carriers disagree — ${hits
524
+ .map(
525
+ (hit) =>
526
+ `${hit.path} advertises lcp:${hit.ref.type}:${hit.ref.value}`,
527
+ )
528
+ .join(", ")}`,
529
+ );
530
+
531
+ const ref = sole.ref;
532
+ if (ref.type !== "sha256")
533
+ throw new Error(
534
+ `${protocol} advertises an lcp:${ref.type}: reference; the gate compares the advertised value against a recomputed record hash, which only a sha256 carrier can be`,
535
+ );
536
+
537
+ return {
538
+ protocol,
539
+ advertisedAtrHash: ref.value as `0x${string}`,
540
+ legalContextUrl: readTermsUrl(protocol, wire, manifest, sole.path),
541
+ };
542
+ }
543
+
544
+ /**
545
+ * The terms URL at the field the manifest DECLARES — or which of the three absences this is.
546
+ *
547
+ * `termsUrlField` is a plain dotted path on the manifest: it declares no container of its own, so it is read
548
+ * with `readAtPath` rather than through the reference carrier's container. A manifest that omits it (A2A, ACK,
549
+ * Visa TAP, Mastercard VI) is stating that its protocol has no room for one — a fact, not a gap.
550
+ *
551
+ * NOTHING AT THE DECLARED PATH IS TWO DIFFERENT FACTS, and which one it is turns on where the reference
552
+ * answered from. `termsUrlField` is declared once, beside the canonical `field`; a `readAlso` alias carries no
553
+ * terms-URL declaration of its own. So when the declared path is nested INSIDE the canonical field — x402's
554
+ * `extensions.legalContext.info.legalContextUrl` inside `extensions.legalContext.info` — and the reference
555
+ * answered from an alias instead, the manifest's declaration was scoped to a carrier this document did not
556
+ * use, and its emptiness says nothing about the document. When the declared path is a SIBLING of the canonical
557
+ * field (ACP's `metadata.legal_context_url`, MPP's `methodDetails.legalContextUrl`), it is reachable whichever
558
+ * carrier answered, and empty means empty.
559
+ *
560
+ * `answeredAt` is the path the reconciled reference was read from. `carrierSlots` puts the canonical field
561
+ * first and `integrityHits` preserves that order, so it differs from `manifest.field` exactly when the
562
+ * canonical carrier did not answer.
563
+ */
564
+ function readTermsUrl(
565
+ protocol: ProtocolId,
566
+ doc: unknown,
567
+ manifest: PlacementManifest,
568
+ answeredAt: string,
569
+ ): AdvertisedTermsUrl {
570
+ const field = manifest.termsUrlField;
571
+ if (field === undefined) return { kind: "no-field-declared" };
572
+ const raw = readAtPath(doc, field);
573
+ if (raw === undefined)
574
+ return answeredAt !== manifest.field &&
575
+ field.startsWith(`${manifest.field}.`)
576
+ ? { kind: "undeclared-at-answering-carrier", field, answeredAt }
577
+ : { kind: "declared-field-empty", field };
578
+ if (typeof raw !== "string")
579
+ throw new Error(
580
+ `${protocol} ${field} is not a string: ${JSON.stringify(raw)}`,
581
+ );
582
+ if (!raw.startsWith("https://"))
583
+ throw new Error(`legalContextUrl must be HTTPS: ${raw}`);
584
+ return { kind: "read", url: raw };
585
+ }
586
+
587
+ // ---------------------------------------------------------------------------------------------------------
588
+ // The universal gate parse
589
+ // ---------------------------------------------------------------------------------------------------------
590
+
591
+ /** A parser from one protocol's wire document to the one `GateProposal` every parser produces. */
592
+ export type ProposalParser = (
593
+ wire: unknown,
594
+ ctx: ProposalContext,
595
+ ) => GateProposal;
596
+
597
+ /**
598
+ * Every protocol this build can turn into a complete `GateProposal`, keyed by `ProtocolId`.
599
+ *
600
+ * SMALLER THAN THE PLACEMENT REGISTRY, and the difference is a fact about the protocols rather than a gap in
601
+ * this package. A `GateProposal` carries an OFFER — an amount and a unit — and an offer is protocol-native
602
+ * economics that no `PlacementManifest` declares and LCP does not standardize: x402 quotes it in
603
+ * `accepts[].amount` with a `network:asset` unit, ACP in the row of `totals` typed `total` with an ISO 4217
604
+ * currency, and the remaining seven each differently again. Inventing an offer-locator axis product-side would
605
+ * put protocol knowledge in a second place and put it there UNGATED, which is the one thing the placement seam
606
+ * was built to stop. {@link readAdvertisedTerms} is universal because the reference is; this is not, because
607
+ * the offer is not.
608
+ *
609
+ * ONE PROTOCOL COULD NOT BE DISPATCHED HERE EVEN IF ITS OFFER WERE READABLE. `ap2`'s discriminant is a strict
610
+ * subset of `a2a`'s, so an AP2 envelope always matches two rows and {@link parseProposalUniversal} refuses it
611
+ * as ambiguous before any lookup in this map. An `ap2` entry added here would be unreachable through the
612
+ * universal door; the parser a future unit writes must be exported and called by name.
613
+ *
614
+ * `Object.freeze` for the same reason `PLACEMENTS` is frozen: a published package is consumed as JavaScript,
615
+ * where the type alone does not stop a consumer swapping the parser that decides what a buyer is agreeing to.
616
+ */
617
+ export const PROPOSAL_PARSERS: Readonly<
618
+ Partial<Record<ProtocolId, ProposalParser>>
619
+ > = Object.freeze({
620
+ x402: parseProposalFromChallenge,
621
+ acp: parseProposalFromAcpCheckout,
622
+ });
623
+
624
+ /** Every protocol this build can parse a complete proposal from, in registration order. */
625
+ export function parseableProtocols(): readonly ProtocolId[] {
626
+ return Object.keys(PROPOSAL_PARSERS) as ProtocolId[];
627
+ }
628
+
629
+ /**
630
+ * Parse ANY supported wire into the one `GateProposal` every parser produces.
631
+ *
632
+ * The universal entry point: a buyer no longer has to know which protocol it is on to gate a
633
+ * transaction. Every named parser stays exported — a caller that DOES know its protocol should keep calling
634
+ * the specific one, because a known protocol needs no detection and detection is the only part that can be
635
+ * wrong.
636
+ *
637
+ * Fail-fast on four conditions, all loud: an unidentifiable wire, an AMBIGUOUS wire matching more than one
638
+ * protocol's discriminant, a protocol whose offer this build cannot read, and any refusal from the protocol
639
+ * parser it routes to. There is no "try them all and take the first that works" path — that is a fallback
640
+ * chain, and it would let a malformed document of one protocol be silently reinterpreted as a valid document
641
+ * of another.
642
+ *
643
+ * The named parsers are DELEGATED to rather than reimplemented over the placement manifests, and one of them
644
+ * proves why that is not merely convenient: x402's wire carries `legalContextUrl` in BOTH `accepts[].extra`
645
+ * and `extensions.legalContext.info` — LCP v1.38 §C.4's own illustration uses the first — while
646
+ * `PlacementManifest.termsUrlField` is singular and declares only the second. Routing x402 through the
647
+ * manifest would drop the terms URL from a spec-legal challenge. The reference reconcile generalizes; the
648
+ * terms-URL locator does not yet, so this entry point delegates and {@link readAdvertisedTerms} REPORTS the
649
+ * shortfall as `undeclared-at-answering-carrier` rather than answering an absence it cannot vouch for.
650
+ */
651
+ export function parseProposalUniversal(
652
+ wire: unknown,
653
+ ctx: ProposalContext,
654
+ ): GateProposal {
655
+ const matched = matchProtocols(wire);
656
+ if (matched.length > 1)
657
+ throw new Error(
658
+ `ambiguous wire — it matches the discriminants of ${matched.length} protocols (${matched.join(", ")}); a document that is legitimately two protocols' documents is not resolved by preference, so name the protocol and call its parser directly`,
659
+ );
660
+ const protocol = matched[0];
661
+ if (protocol === undefined)
662
+ throw new Error(
663
+ "could not identify which commerce protocol this document belongs to — no declared discriminant matched",
664
+ );
665
+ const parse = PROPOSAL_PARSERS[protocol];
666
+ if (parse === undefined)
667
+ throw new Error(
668
+ `no buyer proposal parser for ${protocol} — this build parses ${parseableProtocols().join(", ")}. ${protocol} quotes its offer in a shape no placement manifest declares; readAdvertisedTerms("${protocol}", …) reads its LCP reference today`,
669
+ );
670
+ return parse(wire, ctx);
671
+ }