@integraledger/agentic-terms
v0.21.0
Published
Integra Agentic Terms — the buyer-side verify-before-sign gate for agentic purchases. Fetches the terms a seller advertised, recomputes the fingerprint, and halts before any signing key is invoked if they disagree. Works against any seller.
Downloads
6,059
Readme
@integraledger/agentic-terms
Integra Agentic Terms — verify before sign, as a type and as a runtime guarantee.
None of the agentic commerce protocols carries a fingerprint of the terms it is settling. LCP defines one:
the atrHash, a SHA-256 digest over the terms document, which a seller advertises alongside the terms.
This package is the buyer side of that check — it fetches the terms the seller advertised, recomputes the
digest over the bytes actually served, and halts before any signing key is invoked if the two disagree.
npm install @integraledger/agentic-termsFree and open source (Apache-2.0). No account, no key, no token, nothing to sign up for.
It works against any seller — the check is over what a seller publicly advertises, so it is useful whether or not that seller has ever heard of Integra. Nothing here calls home: no telemetry, no callback, no network request other than fetching the terms the seller pointed you at.
import {
type BuyerPolicy,
type GatedSigner,
makeCachingFetcher,
nodeDnsLookup,
parseProposalFromChallenge,
transact,
} from "@integraledger/agentic-terms";
declare const challenge: unknown; // the 402 body the seller returned
declare const policy: BuyerPolicy; // your risk posture — caps, jurisdictions, forbidden clauses
declare const signer: GatedSigner; // your key. Reachable only on Proceed.
const now = () => new Date().toISOString();
const fetcher = makeCachingFetcher({ httpFetch: fetch, now, lookup: nodeDnsLookup });
const proposal = parseProposalFromChallenge(challenge, {
level: 3,
sellerAssurance: "domain-controlled",
});
const result = await transact(proposal, policy, { fetcher, now }, signer);
// result.kind === "signed" only on Proceed; on Decline or Escalate the signer is never called.Two properties do the work. The typed proposal cannot carry natural-language prose, so the terms body can never reach policy evaluation — the prompt-injection boundary is architectural rather than a matter of discipline. And a step's four-valued status maps totally onto a disposition: a failure always declines, and gaps are resolved by the buyer's stated policy, never by a silent default.
The terms fetcher is HTTPS-only, refuses redirects, checks every resolved address is public unicast on every network fetch, and caps the body while streaming. Viem-free: the chain reader for post-settlement mechanical verification is injected.
Runs wherever your agent runs — with one dependency caveat
This package's own source imports no Node built-in. The one Node-specific helper, nodeDnsLookup, is
imported lazily and injected rather than sitting at the top of the graph, so a build that never calls it
never pulls node:dns in. Supply your own HostLookup and the public-unicast check works on a runtime with
no DNS module at all. That much is enforced by the code and is the part worth relying on.
One Node built-in does arrive transitively, and the honest statement is that it is a dependency's, not
ours: @integraledger/lcp-evidence re-exports its CAR support from its index, which pulls multiformats'
Node SHA-2 build, which statically imports node:crypto. So:
| Target | Works | How we know |
|---|---|---|
| Node | yes | measured — every release runs the gate's whole decision here |
| Bun | yes | measured — same run, same assertions |
| Deno | yes | measured — same run, same assertions |
| Workers with nodejs_compat | yes — node:crypto is polyfilled | reasoned from the import graph, not measured |
| A bundler honouring multiformats' browser export condition | yes — it maps to the browser SHA-2 build | reasoned from the export conditions, not measured |
| A plain unbundled ESM import in a browser or service worker | no | reasoned — node:crypto has nothing to resolve to |
The measured rows run against the packed tarball, installed the way you would install it, with the protocol line resolved from npmjs — not against this repository's own tree. Both halves of the guarantee are asserted on each runtime, because a runtime where the gate refused everything would pass a check that only looked for the halt: tampered terms must halt with the signer never reached, and matching terms must sign with the signer reached exactly once.
The gate needs fetch and Web Crypto and nothing else of its own. Removing the last hop is an upstream
change — a subpath export on lcp-evidence so importing one predicate does not drag CAR and multiformats
into every consumer's bundle — and until it lands, the table above is the claim.
The gate is exactly as trustworthy as the ports you give it
GatePorts is a trust boundary, not just a seam for testing. fetcher decides which bytes the fingerprint
is recomputed over, so a fetcher that returns the wrong body defeats verification completely; now dates
every entry in the record. This is not a weakness to fix — injection is what keeps the gate viem-free and
runnable off Node — but it is a property worth stating rather than discovering. Use the makeCachingFetcher
shipped here unless you have a specific reason not to, and hold your ports to the standard you hold the key
they protect.
Paying, on x402
Verifying is one half of a buyer. The other is answering the 402 — and until this helper shipped, that half was the piece every integrator wrote by hand.
The payment primitive is already public: @integraledger/lcp-binding-evm-x402 builds the EIP-3009
authorization whose nonce is the atrHash, which is what welds a payment to a terms document. What was
missing was the ENVELOPE — the PAYMENT-SIGNATURE value that carries that authorization back in the shape
x402 defines. With it, a stock x402 client plus this one package completes 402 → pay → 200 against a
seller serving Legal Context Protocol terms. That is one npm install of distance, not a protocol
difference; zero-install interoperability would be an x402 specification change and is not what this buys.
import {
decodeX402Challenge,
type X402Authorization,
x402PaymentHeader,
} from "@integraledger/agentic-terms";
declare const response: Response; // the seller's 402
declare const authorization: X402Authorization; // nonce = the atrHash you just verified
declare const signature: string; // your EIP-712 signature over it
const challenge = decodeX402Challenge(response);
// …verify the terms FIRST — `transact` above is the gate, and only a Proceed reaches a signing key.
const header = x402PaymentHeader({
challenge,
authorization,
signature,
paymentIdentifier: "pay_01HZ",
assetTransferMethod: "eip3009",
});
await fetch("https://seller.example/resource", {
method: "POST",
headers: { "PAYMENT-SIGNATURE": header },
});The challenge is a required argument, and that is the design rather than an inconvenience. x402 §6.1
says a payment answers ONE offer, and the entry a payer presents is the entry it was served. So the
accepted entry is copied off the 402 rather than assembled from a local template: a field the seller adds
is echoed from the moment it is added, and a payment cannot be constructed from nothing. A hand-built
envelope agrees with the seller on the day it is written and drifts silently after — which is how buyers
came to present payments answering an offer that was never made.
The only field the presentation adds is extra.assetTransferMethod, because the seller does not advertise
one: it is a property of how this buyer chose to pay.
Reading any protocol's document
A buyer that does not know which protocol it is on has two universal entry points, both dispatching through
@integraledger/lcp-placements so that a protocol this package supports is precisely one the build can also
place a reference into.
import {
detectProtocol,
parseProposalUniversal,
readAdvertisedTerms,
} from "@integraledger/agentic-terms";
declare const wire: unknown; // whatever document the counterparty handed you
detectProtocol(wire); // "acp" | … | undefined — never a guess
readAdvertisedTerms("ucp", wire); // { protocol, advertisedAtrHash, legalContextUrl }
parseProposalUniversal(wire, { level: 3, sellerAssurance: "domain-controlled" }); // the full GateProposalreadAdvertisedTerms is universal; parseProposalUniversal is not, and the difference is a fact about
the protocols. The reference is read out of the protocol's own PlacementManifest — every carrier it
declares, not the first one that answers — so all nine registered protocols work and nothing is listed here.
A GateProposal additionally carries an OFFER, and an amount with its unit is protocol-native economics no
manifest declares: x402 quotes it in accepts[].amount, ACP in the totals row typed total, and the
other seven each differently again.
Four protocols are parsed into a full GateProposal today — x402, ACP, AP2 and MPP. Two of the four
are reached through parseProposalUniversal; AP2 and MPP are reached by name, because naming is what
their documents require:
import { parseProposalFromChallenge, parseProposalFromAcpCheckout,
parseProposalFromAp2Envelope, parseProposalFromMppRequest } from "@integraledger/agentic-terms";parseProposalFromMppRequest reads the MPP request body — the payload a WWW-Authenticate: Payment
challenge carries in its request auth-param. It takes methodDetails.atrHash and
methodDetails.legalContextUrl, the fields placement-mpp declares, and the offer from amount and
currency, which the charge intent defines as base units of a currency-or-asset identifier. It is by-name
because MPP's own identity lives in the challenge rather than the body: an amount-and-currency pair is the
shape of nearly every payment document, so nothing in the body says "MPP". The remaining protocols refuse by
name and say why.
Four answers are worth stating plainly, because each is a place a friendlier library would guess:
Ambiguity refuses. Detection collects every discriminant that fires, never the first. An AP2 envelope is an A2A message, and a UCP checkout response shares
id,status,currency,totalsandline_itemswith an ACP session — so those documents come back named twice, and the caller has to say which protocol it is on rather than being handed a coin flip. The two overlaps differ in strength: the ACP/UCP one is contingent on the document, whileap2's rule is a strict subset ofa2a's, so every AP2 envelope matches both and none is reachable throughparseProposalUniversal. AP2 is a detect-and-name protocol here;parseProposalFromAp2Envelopeexists and is called by name.An absent terms URL says WHICH absence it is.
legalContextUrlis a union —read,no-field-declared, ordeclared-fields-empty— because a bareundefinedconflates two different facts.no-field-declaredis a fact about the PROTOCOL: it has nowhere to put a locator, and no document of that protocol can be faulted for lacking one.declared-fields-emptyis a fact about THIS DOCUMENT: the protocol has room and this seller left every declared slot empty. Reporting the second as the first would blame a protocol for a seller's silence.It used to carry a fourth state,
undeclared-at-answering-carrier, and that state is gone because the defect requiring it is fixed. The manifest's terms-URL member was singular, so x402 could declare only one of its two slots: a §C.4-illustrated challenge advertising inaccepts[].extrareally does carry a terms URL, while the single declared path sat empty insideextensions— and calling that "no terms advertised" would have asserted a silence this reader could not see. The member is plural now, every declared slot is read and reconciled, and a slot riding a container the placement owns is declared on that container. There is no carrier a declaration fails to reach, so the state is unreachable rather than merely unused.Carrier disagreement refuses. Where a protocol declares more than one carrier, all of them are read and compared. Two different hashes on one document would let a seller advertise different terms to different readers of it, so this is deliberately stricter than the placement adapter's own
extract, which answers with the canonical field and does not adjudicate the host's document.A located-but-unattested carrier is not a reference. UCP's
links[type=terms_of_service]entry is a discovery carrier: it says where the terms are and attests nothing, and it is skipped rather than accepted as a weaker answer.
One protocol carries nothing that names it — MPP, whose document is the decoded request body of a charge
challenge, an amount and a currency with no protocol marker (its identity lives in the WWW-Authenticate
challenge one layer out). detectProtocol returns undefined for it and PROTOCOL_DISCRIMINANTS records
why, with the citation. Naming it yourself reads it fine.
Requirement ids
This package's source cites short requirement ids — ATA-2, IDN-1, IDN-3, ORC-4 and RCS-4. They are not LCP clause numbers, and
looking for them in the specification will not find them: LCP is cited by section (§8.3.1, §C.2), and
anything of the form XXX-n is from a different document.
They come from Integra's own functional specification of what a complete agent transaction requires — an analysis that predates and motivates this implementation, organised into fourteen families:
| | | | |
|---|---|---|---|
| IDN identity | ASP authority to spend | ATA authority to accept terms | TRM the terms record |
| RCS recourse | PAY payment and settlement | WLD the transactional weld | OFR offer integrity |
| FRC fraud, risk, and compliance | OPS commercial operations | DSC discovery and reputation | ORC orchestration |
| CMP composition | PRS persistence and verification infrastructure | | |
⛔ This package packs its src/, so those citations reach anyone who installs it. They are glossed
here — rather than silently dropped or left unexplained — because a citation a reader cannot resolve is
worse than prose. The same table is in integra-protocol's root README and in all twenty-one of its
package READMEs; this is the same register, said once more where this tarball's reader can see it.
Nothing in this package's behaviour depends on them. Where an id and an LCP section say different things, the section governs, and where only an id is cited the claim is Integra's own.
⭐ TC-0…TC-4 are different, and resolvable: they are the transaction-completeness classes exported as
TransactionClass by @integraledger/lcp-verify, so a reader holding the packages can read the ladder and
what each rung requires. ⛔ They are not in the LCP specification either — like the ids above they are
Integra's own, and the ladder is in fact built out of those same requirement families. The difference that
matters is where each one resolves: a TransactionClass resolves to a published type a consumer programs
against; a requirement id resolves to a register no consumer holds, which is why it needs this table.
Part of Integra Agentic Terms.
