a receipt, not a whitepaper

Somebody paid a node they do not control. The packet crossed two paid hops inside it, and it paid AR.IO on another chain to store this page forever.

You reached this at an ArNS name. The name was bought with real ARIO by the same node, on behalf of the wallet that paid it. Each hop took its own fee against its own payment channel. Every hash below is on a public chain right now. Nothing here is testnet.

This is one request through TOON, a payment-routing layer where sending a message and sending money are the same action. The interesting part is not that a payment happened. It is that the payment crossed two connectors and then two protocols without the client knowing: the client held USDC on Solana and paid once, each hop inside the node took its own fee against its own payment channel, and AR.IO was paid in USDC on Base over x402. The client never touched Base and never held Base gas.

What x402 is and is not, here. Every hop inside TOON is ILP, and each one carries its own covering claim. x402 only ever appears at a boundary with something that does not speak ILP. When the reference chain below was recorded it appeared at two such boundaries: at the front door, where it is how a client's funds get into a payment channel, and at the far end, where the store app bought storage from AR.IO in USDC on Base. AR.IO runs no connector, so that purchase cannot be an ILP hop today. The dashed box below is where ILP stops.

Two paid ILP hops, then one x402 purchase A client pays this node's edge connector in USDC on Solana mainnet. The edge connector keeps a fee and forwards the packet over ILP to a second connector, carrying its own covering claim. The store app behind that second connector then buys permanent storage from AR.IO in USDC on Base mainnet over x402, which is where the ILP graph ends, because AR.IO speaks x402 and not ILP. AR.IO stores the bytes on Arweave. Separately the node paid the ar.io registry in ARIO for the ArNS name pointing at that data. inside ILP: every hop carries its own covering claim Client any wallet edge connector g.drew.ario.xl store connector + the store app AR.IO bundler x402 merchant Arweave permanent storage ar.io registry ArNS name + ANT USDC 61,000 Solana, hop 1 claim 60,000 Solana, hop 2 x402 Base, leaves ILP bytes ARIO, Solana

The pipeline as it ran when the reference chain below was recorded. Since 2026-08-29 the far end changed: the store now buys its storage capacity with ARIO on Solana, through prepaid Turbo credits, so the x402 egress drawn here is history rather than the current mechanism. The receipts it produced are permanent and stay on this page. What replaced it fills the same two ARIO pockets more directly: storage is bought with ARIO, and names are bought with ARIO, so every leg of the current pipeline now converges on the same token.

Why AR.IO should care. A TOON node takes payment in any token on any supported chain and turns it into demand on AR.IO's side, with no change on AR.IO's side. The reference chain below did it over x402 into the bundler's paid pipe on Base; the current pipeline does it more directly, in AR.IO's own token: the node buys its storage capacity with ARIO on Solana and buys ArNS names with ARIO. Either way the client held no ARIO at all, and ARIO demand was created on their behalf. The standing ask is unchanged: AR.IO runs no connector, so nothing it sells can be an ILP hop until it does.

This page's own receipts

The page you are reading was itself stored by the mechanism it describes. It is not an account of something that happened elsewhere, it is the output of the thing happening.

The receipts below belong to the revision before this one. A page cannot contain the transaction hash that stores it, so there is exactly one unavoidable step of indirection here and this is it. That previous revision is still on Arweave, permanently, at the id below, and the Base payment below bought it. This revision was stored later through the same node over the current pipeline, both ILP hops charged on the ADR 0065 schedule. Fetch the id below and diff the two if you want to check that rather than take it.

stepreceipt
Hop 1: client paid the edge connector client channel DcW6wGmZChYD674SnibLYwMJSWzdR4rYwrgq5ecc8efz, watermark nonce 17 to 18, 61,000 base units of real Circle USDC on Solana mainnet
Hop 2: the edge paid the store connector peer channel 27XcKjUVe3SbVfkrj72bqMcZf3QuEasGxci4kberf8fu, covering claim nonce 8 at cumulative 242,700, advancing 60,000 base units on Solana mainnet. That is the edge's own price minus its own fee, so the fee stayed at hop 1 and the rest went on the wire
Egress: the store app paid AR.IO0x9825ebaebc0e52c8…, block 50535811 on Base mainnet, 0.005569 USDC to 0x6A0A10FFD285c971B841bee8892878c0d583Bf67, with winc: 0 in the receipt so it was the x402 path and not prepaid credits
Bytes on Arweave Tlo23UAZrQphbDbIET29lISNNwt7gqoA8XZJH0iJkPg, 174,553 bytes, text/html
Name points at itANT BiW6dxwkphDjKWsDEqHRKMvNoTbebEDb5i83DUhTwsB1 base record set by 1PJ3mzAMKJKTUiqU…

Where the previous revisions crossed the free-tier ceiling on purpose, this one stays under it, and the reason is a bug worth reading. The node's storage account is funded with ARIO-bought Turbo credits, but that balance currently cannot be spent: every upload above the 107,520-byte ceiling is refused no matter how much winc the account holds (turbo-sdk#455, found and filed by this node's operator on 2026-08-30, sibling of #454). So this revision drops three appendices to fit under the line, and says so instead of pretending. The two ILP hops that carried it were charged per kibibyte either way. When #455 is fixed, the appendices and the over-the-ceiling discipline come back.

The reference chain, verified on 2026-08-15

stepwhat happenedproof
Client to node 61,000 base units of real Circle USDC, Solana mainnet, route g.drew.ario.xl channel DcW6wGmZChYD674S…, watermark 2 to 3
Node to AR.IO 0.003843 USDC on Base mainnet over x402, to AR.IO's payTo 0xe4301d07b503be46…, block 50021953
Bytes on Arweave 153,600 bytes, read back in full from three independent gateways J-9eaGJzhYYZ52g3iidy…
Name registered 1,748.629680 real ARIO paid to the ar.io registry for boughtviatoonnode 3PocM661ctRX1j3f…
Who owns the name ANT spawned from the payer's wallet, not the node's BiW6dxwkphDjKWsDEqHRKMvNoTbebEDb5i83DUhTwsB1

The detail that makes the storage payment real rather than subsidised. The upload receipt reports "winc": "0". Nothing was drawn from prepaid Turbo credits, so this was unambiguously the x402 path. AR.IO was paid. A free-tier write would have earned them nothing.

The detail that makes the name purchase real rather than a demo. The ANT belongs to the wallet that paid the node, not to the node. The node spent its own ARIO to register a name against someone else's asset. That is a brokered purchase, and it is the mechanism by which a user holding any token on any chain creates ARIO demand without ever touching ARIO.

Verify it yourself

None of this requires trusting the operator. Three independent checks, each against a public endpoint:

1. The bytes are on Arweave

curl -sL https://ardrive.net/J-9eaGJzhYYZ52g3iidy_jGLI0wrHvvVhpLav-PtP4A | head -c 400
curl -sL https://vilenarios.com/J-9eaGJzhYYZ52g3iidy_jGLI0wrHvvVhpLav-PtP4A | wc -c

The -L matters: ar.io gateways answer a raw transaction id with a redirect to a per-item sandbox subdomain. Any mainnet ar.io gateway works. ar-io.dev will not, and that is not rot: it is the ar.io testnet stack, on a different registry.

2. AR.IO was paid on Base mainnet

curl -s -X POST https://mainnet.base.org \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt",
       "params":["0xe4301d07b503be4634276250b0f9197fa2deab2ab6247fcae7d084ce8bb3dad2"]}'

Look for a USDC Transfer log to 0x6A0A10FFD285c971B841bee8892878c0d583Bf67. The asset is 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, canonical native USDC on Base.

3. The node was paid on Solana mainnet

solana account DcW6wGmZChYD674SnibLYwMJSWzdR4rYwrgq5ecc8efz \
  --url https://api.mainnet-beta.solana.com

solana transaction-history DQCzjWpsDsbwaTkAvzu9pD3QhEK8QAHCmNHvUmgAKdQt \
  --url https://api.mainnet-beta.solana.com

The payment-channel program is 8e7BhzydH1EqL486tw6Lp99BXviH3i5JN8qNpMSNmHj3. The channel PDA is seeded on sorted participants, so on-chain participant_a is whoever sorts lower, not whoever opened it.

4. The name resolves, and the registry agrees who owns it

curl -sI https://boughtviatoonnode.permagate.io/ | grep -i x-arns
curl -sI https://boughtviatoonnode.ardrive.net/ | grep -i x-arns
# x-arns-resolved-id: <the txId this page is stored under>

Use a mainnet ar.io gateway. The name will never resolve on ar-io.dev (testnet registry, Solana devnet) or arweave.net (no longer an ar.io gateway, forked ArNS); a 404 there says which registry the host follows, not whether this name exists.

What this does not prove

Stating the limits plainly, because a demo that overstates itself is worth less than one that does not.

The pricing problem this route surfaced, and the amendment it forced

AR.IO charges per byte. When this page was first stored, a TOON route carried one flat price, by explicit design (Appendix A). Break-even for this route spans roughly 3,000 base units at 100 KB to 60,900 at 2 MiB, a 61-fold spread that a single scalar cannot express. Pick the low number and the operator subsidises every large job; pick the high number and small jobs overpay by up to 61 times. This node deployed the workaround Appendix A prescribes, a second priced route against a second backend, and filed the measurement as connector#984.

The protocol has since answered with the amendment in Appendix B: a price is now a schedule over payload length, a base amount plus a per-kibibyte slope, of which a flat price is the zero-slope case. The length priced is the sealed wrap's, so the connector still never reads what it carries. Per-byte pricing stayed rejected: at six-decimal USDC the measured slope rounds to zero per byte, and probe cacheability is preserved by publishing the schedule rather than probing per size. The flat price the reference chain paid is unchanged by the amendment; it is the schedule whose slope is zero.

The amendment is no longer just text. Since 2026-08-29 this node's live routes carry the schedule: the edge prices base 1,000 + 30/KiB and the terminating store connector base 900 + 30/KiB, the two-route .xl workaround is retired to an alias, and the revision of this page you are reading was itself charged on that schedule at both hops. The measurement in connector#984 became ADR 0065, and ADR 0065 became the running config. That loop closing is what this page exists to document.

A related measurement, since it is widely mis-stated. AR.IO's free tier is not "unlimited under 100 KB". Read from their own service descriptor at upload.ardrive.io:

{
  "freeUploadLimitBytes": 107520,
  "freeTier": {
    "lifetimeBytes": 10485760,
    "ipBytes": 10485760,
    "maxItemBytes": 107520
  }
}

The per-item ceiling is 107,520 bytes, and there are cumulative caps of 10 MiB per wallet and 10 MiB per source IP. The free tier is a trial allowance, not a permanent small-file exemption. Anything storing at volume starts paying, which is precisely the behaviour a storage network wants. The asterisk, as of 2026-08-30: for a Solana-keyed account funded in ARIO, paying is currently impossible above the ceiling (turbo-sdk#455).

Appendices

Primary sources, embedded so this page stays checkable even if every link above rots. That is the point of storing it here.

A. ADR 0020, the pricing decision this page ran into

connector/docs/adr/0020-a-price-is-flat-and-attaches-to-a-handler.md

# A price is flat, attaches to a handler, and buys an answer

**Status:** Accepted, narrowed by [0040](0040-a-verified-payment-is-stated-to-the-app.md) — a delivery whose covering client claim this connector verified itself now states `X-TOON-Payer` / `X-TOON-Amount` / `X-TOON-Chain`. Everything else stands, and [0044](0044-a-probe-answers-what-a-route-costs-and-what-it-does.md) extends handler granularity from price to description. Amended by [0064](0064-a-deadline-bounds-the-wait-for-an-app-not-the-answer.md) (#1183): “value moves whenever the app answered” now reads “and answered in time” — an app that does not answer within the packet’s own deadline is abandoned and the packet refused `R00`. Every answer that does arrive in time is untouched, a `404` included. **Amended by [0065](0065-a-price-is-a-schedule-over-payload-length.md) (#984):** “a price is flat per packet” becomes “a price is a schedule over the packet’s payload length”, of which flat is the zero-slope case. Handler granularity, the app’s obliviousness, cost accumulation and value-on-answer are all untouched.

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

A price is flat per packet, as a fee is. Pricing granularity is handler granularity: one handler,
one price, and an app that wants to charge differently exposes more handlers. The app is told
nothing about the payment that bought its work. A price accumulates into a reject's running total
alongside fees. And value moves whenever the app answered — whatever it answered.

## Context

The Rust connector cannot charge for a termination at all. `StaticRoute` is a prefix and a handler
URL and nothing else (`crates/connector-config/src/route.rs:60`), and the word `price` appears in
`crates/` only in a comment saying "unpriced". Pay-to-write is not unenforced here, it is
unrepresentable.

Pricing the work at a termination looks as though it must be content-sensitive. The relay prices
`basePricePerByte × bytes` with per-kind overrides. But the connector must never learn what a Nostr
kind is (ADR 0006), and the app is payment-oblivious, so it cannot price either. The prototype
resolved this by not resolving it: its route carried a flat price of `1000` and never consulted the
relay's pricing engine.

## Decision

> **Amended by [ADR 0065](0065-a-price-is-a-schedule-over-payload-length.md) (issue
> #984).** A price is a **schedule** over the packet's payload length --
> `base + per_kib × ceil(len / 1024)` -- of which the flat price below is the case whose
> slope is zero, and which is still what every route the fleet runs charges. The paragraph
> below was taken against an app whose work is the same at any size; #984 measured a node
> fronting a per-byte upstream losing money on every job above ~100 KB, across a 61×
> break-even span one number cannot express. What did **not** change: the length measured is
> the sealed wrap's, never anything inside it, so a connector still prices without ever
> interpreting what it carries.

**A price is flat per packet**, exactly as a fee is (ADR 0010). It does not vary with the payload.

**Pricing granularity is handler granularity.** One handler, one price. An app that wants to charge
differently for different work exposes a handler for each, and the operator publishes a route per
handler. The distinction lives in the address space, not in the packet — which is how a connector
prices without ever interpreting what it carries. `Config::load` refuses two differently-priced
routes pointing at one handler, since an app provably cannot tell them apart and the cheaper price
would always win.

> **Narrowed by [ADR 0040](0040-a-verified-payment-is-stated-to-the-app.md).** The paragraph
> below stands for everything the connector cannot honestly assert, and for every delivery it did
> not take the payment for — but a delivery whose covering client claim this connector verified
> itself now states `X-TOON-Payer` / `X-TOON-Amount` / `X-TOON-Chain`, sourced from that claim's
> own chain-namespaced channel key and this route's own price. The objection recorded here was to
> the prototype's _sources_ (the previous hop; the destination's second label), and ADR 0040
> reuses neither. "Not even which destination was addressed" is untouched.

**The app is told nothing about the payment that brought the packet to it** — not who paid, not
how much, not on what chain, and not even which destination was addressed. Not the payer or the
chain: ADR 0017 found both wrong by construction, not merely omittable — `X-TOON-Payer` names the
immediate previous hop rather than the payer on any path longer than one hop, and `X-TOON-Chain`
can carry a payer-supplied value that an app trusts as connector-asserted. Not the amount: that is
this decision's own consequence rather than a separate one — an app that wants to charge
differently for different work already gets that by exposing a different handler, so an amount
header would tell it only what its own route's price already says. Not the destination: the ILP
address routing consumed to reach this handler never travels with the delivery, which is distinct
from the HTTP method and target inside the sealed envelope — exactly what the connector makes of
the app (ADR 0018). Whatever arrives at a handler was paid for, at that handler's one price, and
that is the only fact the app gets.

**A price accumulates into a reject's running total**, alongside the fees of the hops that carried
it, so a probe discovers what a path costs end to end (ADR 0011). Today only the forwarding path
accumulates (`connector.rs:719`) and every reject raised at a termination hardcodes zero. The field
is renamed `accumulated_fee` → `accumulated_cost`; it does not ride the OER encoding, so the rename
is internal. `CONTEXT.md` gains **Cost** for the sum — what a caller must send, and the only figure
ever returned.

> **Amended by [ADR 0064](0064-a-deadline-bounds-the-wait-for-an-app-not-the-answer.md) (issue
> #1183).** The paragraph below gains one word: _in time_. A packet's expiry bounds how long a
> termination waits for its app, so an answer that never arrives before the deadline is abandoned
> and the packet refused `R00` — which is not a new kind of app failure but the existing
> "timed out" case, previously unenforced. Nothing about _which_ answer is untouched: a `404`
> still rides home on a FULFILL, and lateness is the only property of an answer that has ever
> changed a packet's outcome.

**Value moves whenever the app answered**, whatever it answered. An HTTP status is envelope content,
never a packet outcome: a 404 rides home inside a response envelope on a FULFILL. Only the _absence_
of an answer rejects — unreachable, timed out, undecodable, no route. `AppOutcome::Declined` and its
mapping to `f99_application_error` (`connector.rs:740`) go.

**An unpaid request to a priced route is answered with its terms, not with service.** A connector
that receives one returns what it costs and what is needed to pay it (ADR 0022), rather than
performing the work. This is what makes it safe for a connector that sells to be reachable at all:
the failure mode of an unpriced connector in front of a payment-oblivious app — an anonymous free
gateway to that app, which is what #492 discovered — stops existing when the unpaid case has a
defined, useful, unpaid answer.

## Considered options

> **Reversed in part by [ADR 0065](0065-a-price-is-a-schedule-over-payload-length.md).**
> Both grounds below were answered rather than overridden. _Asymmetry with the flat fee_
> stands as written and is now deliberate: a fee buys carriage, whose work does not scale with
> a payload, and only the price gains a slope. _Cacheability_ is preserved by publishing the
> **schedule** on the greeting and the self-description (`extra.pricePerKib`), so one free
> read answers every size and no sender probes with a same-size dummy. The unit is per
> **KiB** and not per byte, for a reason this record's own ADR 0010 lineage supplies: at
> 6-decimal USDC the observed slope is ~0.03 base units a byte, which rounds to zero in
> integer base units exactly as the basis-point fee did.

**Price per byte.** Covers what the relay actually does. Rejected on two counts: it is asymmetric
with ADR 0010's deliberately flat fee, and it breaks ADR 0011's cacheability — a probe would report
only the cost of a packet its own size, so a sender would have to probe with a same-size dummy
before every write.

**The app quotes each request.** Full fidelity to app-specific pricing. Rejected: it makes the app
payment-aware, and adds a round trip to every packet.

**Reject when the app returns an error.** Intuitive — don't charge for a failure. Rejected: it makes
app errors free, so anyone can drive unlimited load through a connector into an app at zero cost by
aiming at paths that error. That is precisely the traffic a price exists to charge for.

## Consequences

> **Reversed by [ADR 0065](0065-a-price-is-a-schedule-over-payload-length.md) (issue #984).**
> The sentence below is the exact consequence that had to go: a 100-byte and a 100 KB write to
> one handler now cost the same only where the operator wrote a flat price, which remains the
> default and the whole of the deployed fleet. Anti-spam by size follows: a large packet costs
> more, so the cheap-tier abuse the two-route workaround could not prevent stops existing.

Byte-proportional pricing is gone, and with it anti-spam by size at the connector. A 100-byte and a
100 KB write to the same handler cost the same.

The relay's pricing engine loses its consumer. `basePricePerByte` and `kindOverrides` in
`relay/packages/bls/src/pricing/config.ts` have no successor: the route table _is_ the price list. A
relay that wants kind:30023 to cost more exposes a second write handler and the operator prices that
route. Today the relay has one write path, so it has one price.

A payer can be charged for a 500 from a running-but-broken app. The mitigation is the operator
watching its own error rate, not the protocol.

Combined with ADR 0019, fabricating an error response is exactly as profitable to a dishonest
terminating connector as fabricating a success. That consequence is recorded there.

An app fronted by this connector cannot log, bill or rate-limit by payer from what a handler
receives — nothing on that path names one. Anything that needs that attribution must get it from
somewhere other than the packet path.

> **Reversed by [ADR 0040](0040-a-verified-payment-is-stated-to-the-app.md).** A handler now
> receives the paying channel key on the deliveries this connector took the payment for, and can
> log, bill and rate-limit by it. On the deliveries it did not, the paragraph above still holds
> exactly — which is why an app must treat the attribution as optional rather than assuming it.
B. ADR 0065, the amendment this page's route forced

connector/docs/adr/0065-a-price-is-a-schedule-over-payload-length.md

# A price is a schedule over payload length

**Status:** Accepted — **built** (#984). Amends [0020](0020-a-price-is-flat-and-attaches-to-a-handler.md): "a price is flat per packet" becomes "a price is a schedule over the packet's payload length", of which a flat price is the case whose slope is zero. Everything else in 0020 stands, its handler-granularity rule included. Extends [0011](0011-rejects-accumulate-fees-and-probes-discover-cost.md) — cacheability is preserved by publishing the schedule, not by the reject. Narrows [0040](0040-a-verified-payment-is-stated-to-the-app.md): `X-TOON-Amount` is the charge for that packet. [0010](0010-flat-per-packet-fee-and-minimum-delivery.md) and [0061](0061-a-fee-attaches-to-a-peering-not-to-a-route.md) are untouched — a **fee** is still flat.

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

**Falsifier:** `crates/connector-config/src/route.rs` matching `\bmax_bytes\b` — this record defers a per-route payload ceiling to its own decision and says no such field exists. The field cannot be spelled anything else in a route's schema, so its appearance here means the deferral was quietly resolved and this record's "Not decided here" section is stale.

A terminated route's price is **`base + per_kib × ceil(payload_len / 1024)`**, where
`payload_len` is the length of the packet's own `data` — the sealed gift wrap, whose
_contents_ stay opaque to everyone but the termination. A price with a zero slope is flat, and
that is what every route ADR 0020 could express already was. The unit an operator writes is
unchanged for a flat route: `price = 1000` still means what it meant.

## Context

ADR 0020 fixed a price as flat per packet and recorded the consequence in as many words:
_"A 100-byte and a 100 KB write to the same handler cost the same."_ That was correct for
the app the decision was taken against — a relay, whose work is nearly the same at any size.

Issue #984 measured it against one whose work is not. A live third-party node
(`g.drew.ario`) fronts ArDrive Turbo, whose upstream charges by the byte and is itself an
x402 resource server. At the committed `price = 1000` the node loses money on every job
above roughly 100 KB: break-even runs from ~3,000 base units at 100 KB to ~60,900 at the
2 MiB body ceiling. **A 61× span, and one scalar cannot express it.**

The deployed workaround is the one ADR 0020 prescribes — a second route at a second handler,
`g.drew.ario.xl` at 61,000 against a second backend container. It does not work. The tier is
advisory: nothing stops a client sending 2 MiB to the 1,000-unit route, and a client sending
100 KB to the expensive one overpays by up to 61×. `insert_consistent_handler_price` keys on
`handler_url`, so a size tier is a whole second deployment rather than a second price, and
the operator is running two of everything to approximate one number.

This is not the relay's pricing engine coming back. That engine priced by **Nostr kind**,
which requires reading what the packet carries, and ADR 0006 forbids this connector learning
what a kind is. Size is a different kind of fact, and the rest of this record is about why.

## Decision

### The schedule

A price is `base` plus `per_kib` for every **started** kibibyte of payload. Both figures are
in the settlement asset's base units, like every other amount on the value path; nothing
scales by `decimals`. The arithmetic saturates rather than overflowing, so a schedule an
operator writes carelessly answers `u64::MAX` — a charge no claim can cover, which refuses
the packet — instead of panicking on the packet path.

Per **kibibyte** and not per byte, because per byte cannot express the thing this record
exists for: the slope #984 measured is ~0.03 base units per byte at 6-decimal USDC, which
rounds to zero in the integer units amounts are counted in. That is the same defect ADR 0010
removed when it deleted the basis-point fee, and it would have been reintroduced by the
finer unit, not avoided by it.

An empty payload is charged `base` alone. A flat price is `per_kib = 0`, and is _the same
value_ as the bare integer — not a separate case — which is why one handler priced `1000` by
one route and `{ base = 1000, per_kib = 0 }` by another is agreement rather than a conflict.

### The length is the sealed wrap's, which is carriage rather than content

ADR 0020 rejected per-byte pricing partly because pricing at a termination "looks as though
it must be content-sensitive". **It is not, if the measured quantity is the length of the
wrap rather than anything inside it.**

`Prepare.data.len()` is a property of **carriage**, in exactly the sense ADR 0016 gives the
word: every hop already handles those bytes, moves them, and counts them against a frame
limit. Reading their length requires opening nothing. That is what makes the rule uniform
where a content-sensitive one could not be —

- the **client edge** charges it on a forwarded route (ADR 0028), where the payload is sealed
  to somebody else entirely and this node could not read it if it wanted to;
- the **peer price gate** charges it on arrival (ADR 0029), likewise;
- the **termination** charges the same figure for the same bytes, and computes it _before_
  it opens the wrap, so there is no second length inside to disagree with.

And it is a figure the **sender** already has. A sender seals its own envelope, so it knows
the payload length before it sends and can compute the charge itself. Nothing about the
connector's answer is a surprise it has to discover by being refused.

The envelope's _decoded_ length was considered and rejected: it would price the same packet
differently at a forwarding hop and at its termination, because only one of them can see it.

### Cacheability moves to the greeting, and is stronger for it

ADR 0011's second ground against per-byte pricing was cacheability: _"a probe would report
only the cost of a packet its own size, so a sender would have to probe with a same-size
dummy before every write."_

That is true of the reject, and the reject is unchanged: `accumulated_cost` stays a single
sum, evaluated at the probe's own payload length, with no per-hop breakdown and no split
between fees and price. What changes is that a sender no longer has to read the cost off a
reject at all, because **the schedule itself is published**:

- the **x402 greeting** carries `extra.price` (the base) and `extra.pricePerKib` (the slope)
  beside `amount`, which remains what _this_ request costs;
- the **node self-description** (`GET /ilp`, ADR 0050) carries both per priced prefix;
- `GET /ilp/routes/price` carries both.

So one free, unauthenticated read answers **every** size, where before one probe answered
every size only because there was one number to answer with. A sender computes
`cost(len) = probe_cost − charge(probe_len) + charge(len)` for the terminating leg without a
second round trip. The property ADR 0011 wanted — do not make a sender probe per size — is
kept, by a surface that was already free.

`extra.pricePerKib` is **absent**, not `"0"`, on a flat route. Every document a flat route
publishes is therefore byte-identical to what it published before this record, and a reader
written against the flat greeting is unaffected.

### Every gate charges the same figure

There is one rule and it is applied everywhere a price was applied before: **what this packet
costs is the route's schedule evaluated at this packet's payload length.** The client edge's
claim gate, both carriages' greetings, the forwarded route's over-carry bound, the peer
arrival's `F03`, a probe's reject, the envelope-decode `F01`, the deadline `R00`, and a
mismatched fulfilment's reject all read that one figure. `validate_price` itself is
unchanged — its callers compute the charge and hand it in.

### Handler granularity is unchanged

ADR 0020's rule stands word for word, with "price" reading as "schedule": one handler, one
schedule, and an operator charges differently for different _work_ by exposing a handler for
each. What this record adds is that charging differently for the same work at different
**sizes** is no longer a reason to publish a second route at a second handler. The two axes
are separate, and only the size one moves here.

`Config::load` still refuses two routes naming one handler at different prices, comparing
whole schedules — the reason is the one ADR 0020 gave: the app cannot tell which request
arrived under which, so the cheaper would always win.

### What the app is told

`X-TOON-Amount` is the charge for **this** delivery (ADR 0040), which is what its own record
already says it is — _"the price this connector charged, never the amount field of the
arriving packet"_. For a flat route that is the flat price, unchanged. The app is still told
nothing else about the payment, is still payment-oblivious, and still receives no length it
did not already have in its own request body.

## Not decided here

**A per-route payload ceiling** (`max_bytes`). #984 proposes one, and a schedule removes most
of its motive: with a slope there is no longer a cheap tier for a large packet to abuse,
because a large packet simply costs more. What remains — an operator wanting to refuse work
above a size at any price — is a different decision about refusal rather than about pricing,
and it gets its own record. Today the only ceiling is the HTTP body limit.

**App-quoted pricing.** For an upstream whose price moves under the operator's feet — an
Arweave or ArNS buy priced in AR at the moment of purchase — a schedule an operator commits
to a config file is an estimate, and the node wears the difference. #984 names the
alternative: the handler returns a price and the edge collects against it. ADR 0020's ground
for rejecting it stands unchanged here (it makes the app payment-aware and adds a round trip
inside the packet's own ADR 0064 deadline), so this record does not take it. It narrows what
that future record would have to be about: not "prices vary", which is now expressible, but
"prices vary in ways the operator cannot know in advance".

## Considered options

**Per byte rather than per KiB.** The obvious unit. Rejected: unrepresentable in integer base
units at the slope actually observed, as above.

**Two routes and two handlers, as ADR 0020 prescribes.** The status quo, deployed. Rejected
because it is deployed and does not work: the boundary is advisory, so the cheap route takes
the large packets anyway, and the expensive route overcharges the small ones. It also costs a
second backend deployment per tier.

**Price on the decoded envelope's length.** More precise — it excludes the wrap's overhead.
Rejected: only the termination can decode, so the client edge and every peer gate would have
to charge a different figure from the one finally taken, which is the property this record
most needs to keep.

**Leave the reject to answer per size and publish nothing.** The smallest change. Rejected:
it is exactly the defect ADR 0011 named, and it would have made every sender probe before
every differently-sized write.

## Consequences

**A sender pays for the wrap's overhead**, not only for its own content, since the sealed
length is what is measured. It is a constant per packet, and it is a cost the connector
genuinely carries.

**A committed config that gains a schedule is a breaking deploy.** A binary predating this
record refuses the table form by `deny_unknown_fields`, so the image lands before the config,
per the usual ordering. The devnet fleet stays flat by choice, so nothing about it changes.

**The ADR 0028 path invariant now has two halves.** A hop collects `price`, retains a flat
`fee` and forwards the rest, and the path adds up only while every hop's `price − fee` is at
least the next hop's price. With a slope that must hold at _every_ length, which means the
bases must clear the fee **and** each hop's slope must be at least the next hop's — otherwise
a large enough packet erodes to a shortfall that the small ones never revealed. No code
enforces this, for the reason ADR 0028 gives (a connector cannot know what the next hop
charges); `local_topologies_load.rs` holds it for the committed topologies and now asserts
their flatness rather than reading past a slope.

**A runtime peer-route snapshot carrying a schedule cannot be read by an image predating this
record.** A flat one can, in both directions: a flat price serialises as the bare integer it
always was.

**`extra.pricePerKib` and the self-description's `pricePerKib` are new wire surface**, and
under ADR 0045 they are normative prose until a vector covers them. The wire vectors
themselves are unchanged: they carry reject sums and packet bytes, and neither the reject
encoding nor `accumulated_cost`'s meaning moved.

**`docs/devnet-pricing.md` stays the price list**, and the store leg stays at 1000. What this
record changes for the fleet is that pricing the store leg by size is now a config edit
rather than a second box.
C. ADR 0004, value moves on fulfilment

connector/docs/adr/0004-value-moves-on-fulfilment.md

# Value moves on fulfilment, one claim per packet

**Status:** Partly superseded by [0042](0042-a-packet-carries-its-claim.md). The headline — "value moves on fulfilment and only on fulfilment" — is retired. **One claim per packet, never batched, and dead `lockedAmount`/`locksRoot`, are Accepted and still binding.** Its trailing-claim mechanism was inverted by [0031](0031-a-peer-prepare-arrives-with-its-covering-claim-or-it-is-greeted.md), itself superseded by 0042; the flush timer and exposure ceiling it reasons with are retired by [0033](0033-the-exposure-machinery-is-retired-not-restated.md).

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

> **The headline is superseded by [ADR 0042](0042-a-packet-carries-its-claim.md).** "Value moves on
> fulfilment and only on fulfilment" conflated two questions — _when is value owed_ and _what proves
> delivery_ — and answered both with the fulfilment. ADR 0042 keeps only the second: a packet
> carries its own claim, and a fulfilment is a delivery receipt. **What survives here is unchanged
> and still binding: one claim per packet, never batched, and `lockedAmount`/`locksRoot` stay
> dead.** The argument below against prepay is not retracted — ADR 0042 accepts it and bounds it
> with a per-peer packet cap rather than answering it.

A packet's value is owed only when the packet fulfils. The claim covering it follows the
fulfilment rather than riding the outgoing PREPARE, which reverses the existing prepay model
in which the payer paid for the forward _attempt_. Claims remain one per packet: they are not
batched.

> **The peer path is inverted by
> [ADR 0031](0031-a-peer-prepare-arrives-with-its-covering-claim-or-it-is-greeted.md)** (owner
> decision, 2026-08-07, issue #868). On the peer wire a PREPARE now arrives **with** its covering
> claim or it is refused with the x402 greeting, and the credit window this ADR's trailing-claim
> mechanism creates is retired. Everything else here stands: value moves on fulfilment, one claim
> per packet, no batching, `lockedAmount`/`locksRoot` stay dead. The reasoning below for why the
> claim trailed the fulfilment is **superseded, not wrong** — it was correct for a world in which
> a forwarding hop had no way to sign a claim for a packet it had not yet been paid for (issue
> #866 is what supplies one).

## Why the reversal

The old model is documented as deliberate in
the prototype's `docs/local-delivery-fulfillment-contract.md` (§ Reject semantics; deleted with
the prototype, readable in git history), and its argument is sound on
its own terms: the receiving connector is never exposed to an unpaid forward, and a claim
already handed over cannot be voided unilaterally. But the second point is circular — the claim
is unvoidable _because_ it was sent before the outcome was known.

The decisive problem is that prepaying makes the execution condition economically inert. The
peer wire is trustless by default: a hop should be paid only against a preimage it cannot
forge. Under prepay, a hostile next hop takes the claim and rejects the packet, and the
condition prevents only a lie that would gain it nothing it does not already hold. Trustless
forwarding and prepayment cannot both be true.

## Why not batched

Claims are cumulative — each supersedes the last, and only the highest-nonce claim is ever
submitted on-chain — so one claim could cover a hundred packets with an identical on-chain
result and roughly a hundredth of the signing and verification work. That saving was declined
deliberately.

Batching converts the payee's exposure from one packet into one window: between claims, the
payee has forwarded value it holds no signature for, and a payer that vanishes in that window
takes the difference. One claim per packet keeps that exposure at the smallest unit the system
can express, and the signature cost is not the constraint we are trying to relieve — the
architecture is. If per-packet signing later proves to be the throughput ceiling, batching is
reintroducible as a per-peer policy without changing what a claim is.

## Consequences

An ECDSA verify on the inbound claim and a sign on the outbound one sit on the hot path of
every packet at every hop. That is the dominant per-packet CPU cost and should be measured
early rather than assumed acceptable; it parallelises across cores cleanly, since each peering
relation's claims are independent.

The claim now travels after the fulfilment, which the peer wire spec must account for. Sending
it as its own message costs an extra round trip per packet; piggybacking it on the next PREPARE
to that peer costs nothing under load but leaves the final packet of a burst uncovered until a
flush timer fires. The spec needs both mechanisms, and the flush interval becomes the real
bound on trailing exposure.

Benign application-level rejects — a swap node rejecting for staleness or liquidity, a leg-B
failure — stop costing the payer. That behaviour was previously called out as intentional; it
is now simply gone.

`lockedAmount` and `locksRoot` stay dead and are removed from the balance proof and the
on-chain contract. In-flight exposure is bounded by packet expiry rather than collateralised
and arbitrated on-chain.

## Update (issue #1145) — this record's model no longer runs anywhere

The banners above say the headline is retired and the peer path inverted. Both were true of the
_record_ and only partly true of the tree: until issue #1145 the mechanism this ADR describes was
still what actually paid for a forward whenever a peering had no `[[pay_channels]]` row —
`Connector::cover_forward` answered `NotConfigured`, `ClaimBook::record_fulfillment` signed a claim
once the forward fulfilled, and `pending_claim` put it on the next PREPARE to that peer.
[ADR 0042](0042-a-packet-carries-its-claim.md) said so plainly ("Until those land, forwarding runs
0004's model end to end"), and no committed config anywhere wrote such a row except
`local/two-hop`'s payer.

**That code is deleted.** A forward is covered before it is sent or it is not sent: `cover_forward`
has no not-configured arm, nothing arms a peer-role pending claim, and `Config::load` refuses a
peering a `[[routes]]` entry forwards to with no `[[pay_channels]]` row
(`ConfigError::PayChannelUnbound`). `Connector::sweep_flush` and `ClaimBook::due_for_flush` — the
FLUSH sweep this record's Consequences call "the real bound on trailing exposure" — go with it:
nothing can arm a claim for them to sweep, and there is no trailing exposure to bound, because
nothing is ever owed between packets.

**What survives here is exactly what the Status line already said survives**, and it survives
untouched: **one claim per packet, never batched**, and `lockedAmount`/`locksRoot` stay dead. The
"Why not batched" section is unaffected by any of this — under 0042 a packet carries its own claim,
which is the same one-claim-per-packet rule reached from the other side.

`ClaimBook::record_fulfillment` itself is still in the tree, and its survival is not a survival of
this model. It is wrapped by `ClientPayoutLedger` for an unrelated live feature — this connector
paying a **client** back for work it did ([ADR 0026](0026-client-btp-rides-the-client-edge-peers-stay-on-the-peer-wire.md),
issues #699/#770/#779) — where "sign a fresh cumulative claim and arm it pending" is the right
shape and no packet is being forwarded at all. The peer role no longer calls it.
D. ADR 0011, rejects accumulate fees and probes discover cost

connector/docs/adr/0011-rejects-accumulate-fees-and-probes-discover-cost.md

# Rejects accumulate fees; a probe is how cost is discovered

**Status:** Accepted, amended by [0042](0042-a-packet-carries-its-claim.md) (fee honesty is bounded, not self-enforcing), extended by [0044](0044-a-probe-answers-what-a-route-costs-and-what-it-does.md) (a probe also answers what a route _does_) and by [0065](0065-a-price-is-a-schedule-over-payload-length.md) (a price may vary with payload length, so cacheability moves to the published schedule). Fee accumulation, the probe, and the sum-never-breakdown rule are unchanged.

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

Every reject carries a running total of the fees of the hops it has passed through: each hop
adds its own fee before passing the reject upstream. Cost discovery is then a packet you expect
to be rejected — a probe — and the reject that comes back states what the path costs. Probes
are not a distinct packet type, and fee accumulation is not a special mode.

## Why not a quoting protocol

Interledger removed ILQP because a quote is computed over a path chosen at quote time, which
need not be the path a real packet later takes; the answer is precise about a route you did not
use. A probe has no such gap. It is an ordinary packet, routed by the ordinary routing table,
accumulating the fees of the hops that actually carried it.

No single participant can answer the question any other way. Fees are per peering relation —
bilateral, local and private — and no hop can see past its own next hop. End-to-end cost
therefore requires either a global view of the graph or a traversal of it, and traversal is the
one that cannot go stale.

## Properties this inherits from earlier decisions

**The answer is cacheable.** Because ADR 0010 makes fees flat per packet rather than
proportional, a path's cost is a constant that does not vary with the amount being sent. One
probe yields a figure good until the topology or a fee changes. Under a percentage spread the
client would have to re-probe per amount.

> **Extended by [ADR 0065](0065-a-price-is-a-schedule-over-payload-length.md) (issue #984).**
> A terminating route's **price** may now vary with the packet's payload length, so a probe's
> figure is exact for a packet its own size and not for every size. Cacheability is kept, and
> moved: the terminating node **publishes its schedule** -- `extra.price` and
> `extra.pricePerKib` on the greeting, the same pair per prefix on the self-description -- so
> one free, unauthenticated read answers every size, and a sender computes
> `cost(len) = probe_cost − charge(probe_len) + charge(len)` without a second round trip.
> A **fee** is untouched and still flat, so everything this clause says about the carrying
> hops holds exactly as written.

**Understating a fee is unprofitable.** Because ADR 0004 moves value on fulfilment, a hop that
advertises a low fee to attract traffic and then rejects the real packet earns nothing and has
spent its own bandwidth. Honesty needs no enforcement.

**Returning a sum leaks nothing.** The total is what a caller must know in order to use the
path. The per-hop breakdown is not, and is never returned, so topology and individual pricing
stay private.

## Consequences

Making accumulation a property of all rejects rather than of probes specifically means a client
rejected for any reason — expiry, no route, a ceiling — also learns what that path would have
cost. That is strictly more information for strictly less protocol.

A probe traverses the network and pays nothing, so it is accepted only from a sender that
already holds an open payment channel with this connector, and is rate-limited per that
identity. A sender without a channel is rejected at ingress without being forwarded. This costs
legitimate users nothing, since a channel is required to send real traffic regardless, while an
abuser must fund a channel per identity to sustain it.

This closes the price-discovery gap opened by removing `announcePrice` with discovery (ADR 0006) and the x402 greeting. Neither is reinstated.

## Update (ADR 0042)

**"Honesty needs no enforcement" no longer follows, and is replaced rather than dropped.** That
property was inherited from ADR 0004: under postpay, a hop advertising a low fee to attract traffic
and then rejecting the real packet earned nothing.
[ADR 0042](0042-a-packet-carries-its-claim.md) retires that headline — a packet now carries its
claim — so such a hop banks the covering claim and refuses to carry. The hop is at least always one
the operator chose: [ADR 0043](0043-purchasable-peering-is-removed.md) removed the purchasable
peering that would otherwise have let a stranger advertise the bait itself.

**Fee honesty is now bounded rather than self-enforcing.** Two mechanisms ADR 0042 already requires
do the work: the sender's own packet sizing, and the per-peer cap on what this connector will
forward in one packet. Both bound a single dishonest hop to one packet's worth, which is what
postpay used to give for free. The property survives; it stopped being free.

**Everything else in this record is unchanged.** Fees still accumulate on every reject, a probe is
still an ordinary packet rather than a mode, the returned sum still leaks no per-hop breakdown, and
the answer is still cacheable because ADR 0010's fees are flat. Probe economics in particular need
no revision: a probe carries a small amount by construction, so what a forwarding connector now
covers on one is a micropayment, against an abuser who must still fund a channel per identity and is
still rate-limited per that identity.
E. ADR 0022, a connector answers, it does not announce

connector/docs/adr/0022-a-connector-answers-it-does-not-announce.md

# A connector answers when asked; it still never announces

**Status:** Accepted. One **consequence** is superseded by [0027](0027-connectors-peer-over-btp-or-http-and-the-raw-tcp-peer-wire-is-deleted.md) — the peer wire is no longer "private, plaintext and unauthenticated on its own segment"; it is deleted. Read alongside [0030](0030-an-operator-announces-a-node-the-node-still-does-not.md) for who the verb belongs to, and [0044](0044-a-probe-answers-what-a-route-costs-and-what-it-does.md) for what an answer now carries.

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

A connector tells whoever asks what its own configuration already says — its identity, and what a
route of its costs. It still never pushes that into a network unprompted. A sender **asks directly
and pays through the network**, which is what makes the answer trustworthy.

## Context

ADR 0018 requires a sender to hold the terminating connector's public key before it can form a
packet at all. Getting that key wrong is not a delivery failure: an attacker whose key is used can
read every envelope, which is precisely the confidentiality ADR 0018 was adopted to obtain.

The obvious route — carry the key back in a reject, as ADR 0011 carries accumulated cost — does not
survive contact with the threat model. Hops rewrite rejects by design (`connector.rs:719` adds each
hop's fee to a reject passing back), so any hop on the path can substitute its own key into a
greeting in flight. Intermediaries are exactly the parties positioned to do this, and exactly the
parties the wrap exists to defend against. Signing the greeting does not help, because verifying the
signature requires the key being learned.

Meanwhile a negotiation surface is needed regardless, for connector-to-connector peering: two
operators deciding to peer have to exchange identities and terms somehow.

`CONTEXT.md` and ADR 0006 say flatly that "the connector never learns, announces, or discovers", and
ADR 0011 removed both `announcePrice` and the x402 greeting, saying "Neither is reinstated." Taken
literally that forbids the endpoint. Taken as written it does not, because two different things had
been sharing one word.

## Decision

**Answering is not announcing, and a connector does the first but not the second.**

- **Announcing** is pushing facts about yourself into a network unprompted — `announcePrice`,
  kind:10032 self-announce. A connector never does this. Deciding to participate in a discovery
  network is the controller's business, and ADR 0006 stands unchanged.

  > **Read alongside [ADR 0030](0030-an-operator-announces-a-node-the-node-still-does-not.md)
  > (2026-08-05, issue #784):** the second sentence of this bullet is the operative one. A running
  > connector still never announces — no timer, no startup broadcast, nothing on the packet path.
  > An **operator** may, by running `connector announce` on the node itself: the controller
  > deciding, once, with the identity key never leaving the box and the write paid for like any
  > other. ADR 0030 does not weaken this rule about the process; it names who the verb belongs to.

- **Answering** is telling whoever asks what your own configuration already says. It decides
  nothing, and reaches nobody who did not ask. `GET /identity` on the operator surface is already
  this, for a different audience.

A connector answers **on the client edge**, the surface already public and already defined as the
one whose far end is "installed on machines the operator does not control". A request either carries
payment and is served, or does not and is answered with the terms (ADR 0020). Both cases live on one
port because they have one audience and one exposure, and are already separated by whether payment
was attached.

The key property is the asymmetry between the two paths: **a sender asks the terminating connector
directly, over its own connection, and pays through whatever path routing chooses.** Nothing carries
the answer but the connection that requested it, so there is nothing in between to substitute. The
x402 body shape (`accepts[]`, a list of acceptable payment methods) is a good fit for what an answer
must carry, since terms are plural.

For peering the same endpoint serves: both ends of a peer wire are operator-controlled by
definition, so two operators who have decided to peer exchange endpoints out of band and verify over
a direct connection.

## Considered options

**A signed announce binding address to key**, verified against an identity the client already
trusts. Genuinely solves substitution, and the org has the machinery. Rejected as the primary
mechanism because it needs a trust root, a distribution path and a revocation story to answer a
question a direct connection answers for free — and because the endpoint has to exist anyway for
peering. It remains the fallback if a terminating connector ever cannot be reached directly.

**Trust on first use with pinning.** Cheap; makes substitution detectable after the fact rather than
prevented. Rejected: first contact is the contact that matters.

**A second, separate port for answering.** Answering could never be confused with serving, and the
two could be firewalled apart. Rejected: it doubles what must be bound, TLS'd and rate-limited to
separate two cases already separated by whether payment was attached.

## Consequences

**A connector that terminates a priced route must be reachable by anyone who may buy from it**, even
when no packet is ever routed to it directly. On devnet a client sends its packet to the apex and
the apex forwards over the peer wire, so the store connector need not be reachable today. Under this
decision it must be — to be _asked_, while still being _paid_ through the apex. Ask direct, pay
routed.

This does not disturb #492's finding about the peer wire, which stays private, plaintext and
unauthenticated on its own segment. It is the client edge that becomes public on boxes where it is
not, and that is a different port with different exposure.

> **Superseded consequence (2026-08-03,
> [ADR 0027](0027-connectors-peer-over-btp-or-http-and-the-raw-tcp-peer-wire-is-deleted.md)):** the peer
> wire no longer "stays private, plaintext and unauthenticated on its own segment" — it is deleted,
> and connector↔connector traffic becomes an authenticated, TLS-terminated BTP session on a public
> `wss://` URL. This ADR's own decision — a connector answers, it does not announce — is unaffected,
> and its "ask direct, pay routed" shape is unchanged.

An unauthenticated public endpoint returning identity and prices is a denial-of-service surface, and
prices stop being private. Both are accepted as the cost of selling.

**Paying over HTTP is deliberately deferred, not rejected.** A connector fronting an app could
plausibly accept a plain HTTP request with payment attached — the x402 onramp, for a client with no
ILP stack and no channel — and answer `402` with terms when payment is absent. That is a second
architecture with its own payment verification (a one-shot exact payment settles per request, which
inverts ADR 0004 and 0005's "claims are constant, settlement is rare"), and it is out of scope here.
Answering over HTTP while paying over ILP is a different and smaller thing, and is what this ADR
decides.
F. ADR 0005, claims are truth and balances are a projection

connector/docs/adr/0005-claims-are-truth-balances-are-a-projection.md

# Claims are the source of truth; balances are a projection

**Status:** Accepted, amended by [0033](0033-the-exposure-machinery-is-retired-not-restated.md). Claims-as-truth and the replayed journal stand. The exposure and ceiling arithmetic named under "Consequences" is retired — nothing projects exposure any more. The crate it names, `connector-core`, shipped as `connector-domain`.

**Scope:** connector architecture — internal to this codebase. See the [ADR index](README.md).

The connector durably persists only what is signed or otherwise irreversible — claims sent,
claims received with their watermarks, and fulfilments not yet covered by a claim. Per-peer
balances and credit-limit positions are an in-memory projection rebuilt from that journal on
start. There is no ledger abstraction and no TigerBeetle.

## Why

Under ADR 0004 the claim is the thing of value: signed, cumulative, superseding. A balance is
just an arithmetic consequence of the claims exchanged and the fulfilments since the last one.
Storing it as independent authoritative state creates a second thing that can disagree with
the first, and the reconciliation between them is work with no upside.

TigerBeetle is being dropped because it was never real. It appears nowhere in
`docker-compose*.yml`, `deploy/`, `infra/`, `config/connector.prod.yaml` or the `Makefile` —
only in `src/` and tests. Every deployed node has always fallen back to
`InMemoryLedgerClient`. What we actually carried was a `LedgerClient` port, two
implementations, a batch writer, an error-mapping layer, a dual-mode `AccountManager` and an
optional peer dependency, all to keep alive an option nobody exercised.

An official Rust client exists on the 0.16 line, so this is reversible if throughput ever
demands it. Re-adding a backend to a system with one concrete implementation is a bounded
task; carrying a port for a hypothetical second one is a permanent tax.

## Consequences

Recovery is replay, not reconciliation. On start the connector reads its journal and
recomputes balances. Correctness therefore depends on the journal being written before value
is considered moved, which is a much easier property to test than agreement between two
stores.

The projection must be reconstructible by pure code. That puts it in `connector-core` — no
async, no I/O — so the arithmetic that decides whether a peer is over its ceiling is
property-testable without a database, a chain, or a network.

Losing double-entry means losing its built-in `sum(debits) == sum(credits)` check. The
replacement invariant is that every peer's projected balance equals the delta between the
cumulative in its latest sent claim and its latest received claim, plus uncovered
fulfilments. That is checkable on every projection rebuild, and should be.
G. ADR 0006, the connector is mechanism, not policy

connector/docs/adr/0006-the-connector-is-mechanism-not-policy.md

# The connector is mechanism; discovery and route policy live outside it

**Status:** Accepted. **Restored without qualification by [0043](0043-purchasable-peering-is-removed.md)** — its one exception, a peering that could be bought, is gone. Extended rather than replaced by [0034](0034-a-runtime-peer-route-table-never-shadows-the-config-file.md): a runtime peer/route row is a third shape beside static and leased. Its forward reference to a "sold peering (#867)" lapsed with the feature.

**Scope:** connector architecture — internal to this codebase. See the [ADR index](README.md).

The connector forwards what it is told to forward and settles what it is told to settle. It
does not decide who its peers are, does not learn routes, and does not announce itself.
Discovery is removed entirely; the operator surface exposes CRUD over the routing table and
over payment-channel lifecycle, and an external controller drives both.

## What this removes

All 4,028 lines of `discovery/`, plus `routing/link-state-db.ts` and
`routing/path-computation.ts` — the link-state database and Dijkstra belonged to route
learning, not to forwarding. With them go the `nostr-tools` dependency, NIP-06 key
derivation, NIP-40 expiry handling, the relay WebSocket client, the bootstrap seed list, the
learned-relay cache, the signed seed manifest and its pinned curator key, and the paid
self-announce write path.

`PeerDiscoveryService` was already dead — exported from `discovery/index.ts` and never
constructed — and is not carried forward under any name.

## Why

Discovery is a routing protocol that happens to use Nostr as its flooding substrate. Bundling
it into the forwarder made the forwarder untestable without a relay, gave one component
privileged in-process access to the routing table that no external caller had, and coupled
the connector to a stack it otherwise has no reason to know about. Splitting them makes
routing a pure function of state that can be set directly, and forces the operator API to be
complete because nothing can reach around it any more.

## Consequences

**Route withdrawal must be preserved, so routes are leased.** Static routes come from config,
persist across restarts, and always win. Dynamic routes are pushed through the operator API
with a TTL and lapse unless renewed. This keeps the safety property that route learning
provided — a peer that stops being refreshed loses its routes — without the connector knowing
why. A controller that dies causes routes to expire rather than to rot, and a stale route
pointing value at a peer that can no longer deliver is the failure this prevents.

**Extended, not replaced, by issue #884.** A sold peering (#867) is a deliberate, paid
relationship rather than an automated controller's push, so it needs a third shape beside
"static, from config" and "leased, TTL-bound": runtime-mutable AND durable. ADR 0034 adds it
without disturbing either existing one — "static routes... always win" still holds; a runtime
row can never take a key the config file owns.

**Nothing announces any more, and that gap is now external.** An empty bootstrap seed
previously produced hardcoded address literals and 404s for new users. Self-announcement and
route learning are moved, not dropped, and the component that owns them has to exist
somewhere else before the network can grow past its static configuration.

**The operator API is now money-critical.** Route CRUD decides where value goes, and channel
operations move funds. It requires real authentication and audit, on the same footing as the
settlement code — not the shared-token treatment an inspection API would deserve.
H. ADR 0018, a payload is sealed to the terminating connector

connector/docs/adr/0018-a-payload-is-sealed-to-the-terminating-connector.md

# A packet's payload is sealed to the terminating connector

**Status:** Accepted. Bounded by [0032](0032-a-client-destination-is-never-a-route-termination.md) — "the terminating connector" means a route termination, never a client destination. Live: `connector-signer`'s gift wrap, `connector-domain::envelope`. **Amended by [0054](0054-an-unsealed-termination-reject-answers-where-to-ask.md)** (issue #1071): a reject raised _at_ a termination is **not always sealed** — a termination that never recovered the shared secret (no identity key, or a wrap it could not open) answers in plaintext. `CONTEXT.md` carried the correct law throughout and this record did not. Its `GET /identity` citation is one path segment short of `/ilp/identity`; the key reported there is the sealing key. **Its Consequences contradicted themselves** on key discovery — "not settled here" in one paragraph, "ADR 0022 settles how" four paragraphs later; [0022](0022-a-connector-answers-it-does-not-announce.md) settles it, and the second Update below is the correction.

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

A packet's `data` is always a gift wrap addressed to the identity of the connector that terminates
its route. Inside sits a structured envelope and the secret that packet's fulfilment derives from.
Opacity in carriage stops being a rule hops are asked to keep and becomes one they cannot break.

## Context

ADR 0016 established that a forwarding hop never interprets a payload and a terminating one does,
and left that as a rule. Nothing prevented a hop from reading a payload it was merely forbidden to
read. The payload was also plaintext, so every hop on a path could see the method, target, headers
and size of every request crossing it. For a network whose proposition is paid carriage between
parties that need not trust one another, "we agreed not to look" is a weak guarantee and an
unmeasured leak.

The prototype's envelope was an HTTP/1.1 request as text (ADR 0017). Parsing attacker-supplied text
at the point where money changes hands invites the vulnerability classes that exist _because_ HTTP
framing is subtle — request smuggling, header injection, ambiguous body boundaries. The prototype's
own parser already showed the shape of it.

## Decision

**Every packet's `data` is a gift wrap**, and the seal runs in both directions.

**On a PREPARE**, the sender seals to the terminating connector's identity key — the key that
connector already holds (`connector-signer`, uncompressed secp256k1, reported at `GET /identity`).
The wrap carries two things:

- **the envelope**, as a _structured_ encoding — a method, a target, headers and a body going in; a
  status, headers and a body coming back — not as HTTP text. `connector-domain` already carries an
  OER codec (`oer.rs`) for packets.
- **a shared secret**, from which the fulfilments of that packet and its successors derive
  (ADR 0019).

**On a FULFILL, and on a REJECT raised at the termination**, the terminating connector seals its
answer back with that same shared secret. No second exchange is needed; the secret is bidirectional
by construction. Sealing only the request would have left the app's answer — a store's returned
content, a relay's confirmation — readable by every hop on the return trip, which is half the
conversation and was never the intent.

**A reject raised short of the termination is necessarily plaintext.** An intermediate hop rejecting
for no-route, expiry or ceiling shares no secret with the sender and cannot seal anything.

`accumulated_cost` (ADR 0020) stays **outside** the wrap in every direction: each hop adds its own
fee to a reject travelling back, so that field cannot be sealed. The separation already exists —
`packet.rs:388` asserts the running total does not ride the OER encoding but beside it.

Only the intended reader can open a wrap. Every other hop carries bytes it cannot read.

## Considered options

**Carry the secret as a header of a plaintext envelope.** Cheaper, and conformant with the
prototype's format since its envelope already carries arbitrary headers. Rejected: it leaves the
envelope readable, so opacity stays a norm, the metadata leak stays open, and any hop that peeks can
take the secret and be paid without forwarding.

**Keep the envelope as HTTP text inside the wrap.** Readable with `xxd`, and nearly a pass-through
to the app. Rejected: the wrap already removes plaintext inspection, so the debuggability argument
buys nothing — and a lenient text parser at a paid boundary is the sharpest edge in this design.

## Consequences

Every terminated packet now costs an ECDH and an AEAD decrypt, on the packet plane. This has not
been measured. ADR 0015 was written over a `HashMap` clone per packet; this is heavier, and should
be measured before it is load-bearing.

Operators lose plaintext packet inspection, including in logs. Anything that needs to read an
envelope needs the key.

Discovery becomes a hard dependency. A sender must know the public key of the connector terminating
its destination before it can form a packet at all. Under a plaintext envelope a wrong guess meant
"delivered but unpaid"; now it means undeliverable. How a sender learns that key is not settled
here.

The app is unaffected. It is handed ordinary HTTP by a connector that has already unwrapped and
decoded, so "any HTTP service is a TOON node app" still holds. What changed is only the format
between a sender and the connector that terminates for it.

A sealed reject is **authenticated**, and that is a gain rather than a side effect. Only the
terminating connector holds the secret, so only it can produce one — which means a sender can
finally distinguish _"the destination said no"_ from _"someone on the path said no."_ Today those
are indistinguishable, and any hop can forge the former.

The sender must hold the terminating connector's public key before it can form a packet at all, and
must have obtained it in a way an intermediary cannot have tampered with. ADR 0022 settles how.

## Update — the wrap is deliberately **unauthenticated**, and that is a property, not an omission

This record settles that only the intended reader can open a wrap. It never states the converse, and
the converse is equally load-bearing: **the wrap says nothing about who sealed it, and cannot be made
to.**

### The construction

`connector_signer::giftwrap::seal_request` runs ECDH between a **fresh per-packet ephemeral secret**
and the receiver's identity public key. **No sender key participates.** The sealed request is:

```
0x01 ‖ ephemeral secp256k1 public key (65 bytes, uncompressed) ‖ AEAD ciphertext
                                                                 └─ shared_secret(32) ‖ envelope
```

The ephemeral public key rides in the clear, because the receiver needs it to redo the ECDH — but it
is drawn fresh from the CSPRNG for every packet, so it is unlinkable across packets and carries
nothing about its author.

**The shared secret is not derived from the ECDH.** It is 32 independent CSPRNG bytes, sealed
_inside_ the envelope the ECDH-derived key encrypts. That separation is what the rest of this update
rests on.

### What it buys

- **Deniability, in the strong sense.** The secret is a function of no key pair, so holding it
  evidences nothing about who produced it. A receiver can trivially fabricate a wrap "from" anybody —
  sealing needs only a public key — so a receiver can never demonstrate to a third party that a given
  wrap came from a given sender.
- **Unlinkability.** Two packets from one sender share no observable value. The ephemeral key is the
  only sender-side material on the wire and it is fresh each time.
- **Nothing to compromise later.** There is no long-term sender key whose disclosure would retro-open
  past wraps, because there is no long-term sender key in the construction at all.
- **A fulfilment proves opening, never identity.** [ADR 0019](0019-a-terminating-connector-derives-the-fulfilment.md)'s
  derivation runs over the random secret, so producing a valid fulfilment demonstrates that you opened
  the wrap or were handed what was in it — and demonstrates nothing else. This is what keeps ADR 0054's
  sealed-reject property about _the destination_ rather than about a named party.
- **The receiver's key never leaves its boundary.** `open_request` goes through `Signer::ecdh`, so a
  KMS backend opens a wrap without exposing secret key material.

### What it costs, and the rule that follows

**Zero sender authentication.** Anyone holding a node's public identity key — which is served to
whoever asks, by [ADR 0022](0022-a-connector-answers-it-does-not-announce.md) — can seal a wrap that
opens cleanly. It follows that **a terminating connector MUST NOT treat "this wrap opened" as evidence
of who sent it**, and MUST NOT derive authorisation from it. Authorisation comes from the claim that
paid, never from the payload that opened.

Deniability and authentication are the same coin here. Taking sender authentication would mean binding
a sender key into the seal, and every property above would go with it.

### The alternative this rejects, stated so it is not re-proposed

Deriving the shared secret from a **static-static** ECDH — the sender's long-term key against the
receiver's — is the obvious cheaper construction, and it destroys all of the above at once: the secret
becomes a cryptographic binding between two named identities, either party can later evidence the
other's participation, and compromise of one long-term key retro-opens every wrap ever exchanged with
it. Minting the secret independently and carrying it inside the wrap is what avoids that, and is why
it is done that way rather than derived.

### Two things this does not say

**It is not the claim wrap.** `connector_signer::nip59` — the client-edge
`ILP-Payment-Channel-Claim-Wrapped` header — has an inner **seal layer that IS ECDSA-signed by the
sender**, so its receiver does learn who sent it and only outside observers do not. Opposite property,
different path; the two must not be conflated.

**Deniability is a property of the wrap, not of the path.** Every hop authenticates its _immediate_
upstream and records it durably: an operator origination is RFC 9421-signed, a peer crossing carries
the peering credential and a claim on a configured channel, a client arrival carries its client claim,
and accepted claims land in the journal. So a hop knows who handed it the packet and does not know who
handed it to _them_. What the wrap withholds is the **original sender**, from everyone including the
termination.

## Update — key discovery **is** settled, and this record said both things

Two paragraphs of Consequences above disagree with each other. One says _"How a sender learns that
key is not settled here."_ Four paragraphs later another says the sender _"must have obtained it in a
way an intermediary cannot have tampered with. ADR 0022 settles how."_ Both were left standing. **The
second is right**; the first is stale and should be read as superseded by it.

[ADR 0022](0022-a-connector-answers-it-does-not-announce.md) settles discovery: a connector
**answers** what its own configuration already says — its identity, and what a route of its costs —
and a sender **asks it directly**, over its own connection, paying through whatever path routing
chooses. `GET /ilp/identity` is that answer. The guarantee is structural rather than cryptographic:
_"Nothing carries the answer but the connection that requested it, so there is nothing in between to
substitute."_

**What that does and does not defend.** The threat ADR 0022 names is a hop **on the packet path**
substituting its own key — it can, because a greeting and a reject pass back through it. Asking
directly defeats exactly that, by taking the question off the path the packet travels. It does not
make the direct connection itself tamper-proof; that is the transport's job, and ADR 0022's own
consequences assume a TLS-terminated one. A sender dialling `http://` — as every `local/` topology
does, behind `peer_allow_plaintext_endpoints` — has the structural guarantee and not the transport
one.

**And a substituted identity key is not detectable after the fact.** `connector send` does carry an
`Outcome::FulfilledWithWrongFulfillment` check, but it catches a node answering with a fulfilment it
could not have derived — not an attacker who served their own key at `/ilp/identity`. That attacker
opens the wrap legitimately, recovers the secret, derives a valid fulfilment, and is paid without
ever delivering. The check stays silent, because from the sender's side nothing is wrong. This is why
ADR 0022 rejected trust-on-first-use with pinning as the primary answer — it makes substitution
detectable only afterwards — and why it keeps a signed announce binding address to key as the
**fallback** for a terminating connector that cannot be reached directly.
I. ADR 0019, a terminating connector derives the fulfilment

connector/docs/adr/0019-a-terminating-connector-derives-the-fulfilment.md

# A terminating connector derives the fulfilment it is paid against

**Status:** Accepted. Bounded by [0032](0032-a-client-destination-is-never-a-route-termination.md), extended by [0064](0064-a-deadline-bounds-the-wait-for-an-app-not-the-answer.md) — which states the one condition under which a termination declines to derive at all: the packet's deadline fired before the app answered (#1183). Live: `Connector::deliver_opened_envelope`. The `TOON-Fulfillment` header it retires is gone from `crates/`.

**Scope:** protocol law — binds every implementation, not just this one. See the [ADR index](README.md).

At a route termination the connector derives the packet's fulfilment from the secret in the gift
wrap, rather than receiving it from the app. Issue #417's rule — that a connector never produces a
fulfilment itself — is kept for forwarding hops and dropped at terminations.

## Context

Issue #417 closed the derived-preimage hole, and closed it thoroughly.
`connector-domain::condition` states it outright: there is deliberately no function anywhere in that
module from a condition to a fulfilment. `Connector::accept_if_fulfilled` documents the same rule
from the other side — it exists to prevent "an intermediate hop (relaying a peer's answer) or a
terminating one (relaying an app's) from producing a valid fulfilment without the destination's
actual participation", and never accepts "a fulfilment this connector invents itself".

That rule and envelope delivery cannot both hold. The prototype's own normative contract says so —
`docs/local-delivery-fulfillment-contract.md` (deleted with the prototype, readable in git
history), rule 5:

> Handlers that structurally cannot supply preimages (e.g. the #216 HTTP reverse-proxy for
> terminated routes) fulfill without one and are therefore converted to F99 by rule 3; do not point
> sender-chosen traffic at them.

The prototype made envelope delivery work through its _legacy class_: an absent or all-zero
condition, no verification, and a receiver-side preimage the connector injected from an NIP-59/HKDF
derivation. The Rust connector deleted that class — an all-zero condition is invalid outright, never
a legacy auto-fulfil path. So it can decode an envelope or it can honour #417, and not both.

Underneath sits a trade that cannot be dodged:

> You cannot have both _"any HTTP service is a TOON node app"_ and _"only the true recipient can
> produce a fulfilment."_

A payment-oblivious HTTP service holds no secret and performs no cryptography, so it cannot mint a
preimage. Someone else must — and whoever does can fulfil without delivering.

## Decision

**The terminating connector derives the fulfilment**, from the shared secret the sender sealed to it
(ADR 0018). The app supplies nothing, and the `TOON-Fulfillment` response header goes away.

#417's protection is unchanged where it was aimed. A forwarding hop still cannot produce a
fulfilment, and is still paid only against a preimage it verifies. The reasoning is that a
condition's trustless property protects a payer from parties it never chose and cannot see. A
terminating connector is not one of those: it is the counterparty the payer deliberately addressed,
in the same trust domain as the app behind it.

## Considered options

**The app supplies the preimage**, derived from an end-to-end secret with the sender — the
prototype's sender-chosen class, and #417's assumption. Cryptographically the strongest option on
offer: only the true recipient can fulfil, and no connector anywhere can forge one. Rejected because
it makes every app condition-aware, which deletes the payment-oblivious app and with it the goal
that any HTTP service can be a TOON node app.

**The sender reveals the preimage inside a plaintext envelope.** Keeps the app oblivious and needs
no derivation. Rejected: any hop that reads a payload it is only _asked_ not to read can take the
fulfilment and be paid without forwarding. ADR 0018 makes this moot in any case.

## Consequences

A dishonest terminating connector can fulfil without delivering — take payment, never call the app,
and return a fabricated response envelope. ADR 0020 sharpens this rather than softening it: because
value moves whenever the app answered, _whatever_ it answered, fabricating an error response is
exactly as profitable as fabricating a success.

The defence is not cryptographic, and this ADR does not pretend otherwise. It is that the payer
chose this counterparty, that the response envelope is evidence of what the connector claims
happened, and that a connector doing this systematically is identifiable and can be refused as a
peer.

The identity key becomes load-bearing for fulfilment, not only for signing claims. Rotating it
invalidates conditions already minted against the old key, so rotation needs an overlap window. That
window is not specified here.

`AppOutcome::Delivered`'s `fulfillment` field, `decode_fulfillment_header`, and the
`TOON-Fulfillment` header have no remaining purpose.
J. ADR 0012, a signer and a treasury, not a wallet

connector/docs/adr/0012-a-signer-and-a-treasury-not-a-wallet.md

# The connector holds a signer and a treasury, not a wallet

**Status:** Accepted in part. The **signer half is live** — `connector-signer::Signer` / `LocalSigner` / `KmsSigner`. The **treasury half is retired**: `connector-signer::treasury` was deleted by issue #556 with no successor record, and `connector-settlement`'s `SettlementBackend` does the collateral job. Nothing named `Treasury` remains in `crates/`.

**Scope:** connector architecture — internal to this codebase. See the [ADR index](README.md).

**Falsifier:** `crates/**/*.rs` matching `\bTreasury\b` — this record's Status line says nothing named `Treasury` remains in `crates/`; a live use of the name would mean the treasury half was rebuilt without a record deciding to, which is what issue #556 deleted it to prevent. (Comment lines are skipped, so `connector-signer`'s header may keep explaining what it was.)

Key handling collapses to one crate exposing a `Signer` — a local key or a key management
service backend, with rotation — and a treasury account that funds payment channels and pays
gas. Mnemonic recovery, seed management, human wallet authentication, the wallet database and
the fraud-detection rule engine are removed.

## Why

The existing `wallet/` and `security/` directories are two parallel stacks over the same four
concerns, each with its own key manager, audit logger, rate limiter and fraud detector. That
duplication is a symptom: nobody owned the concern, so it grew twice. Collapsing it required
first deciding what the connector actually needs, and the answer is much smaller than what is
there.

A connector needs to sign claims and settlement transactions, and needs an on-chain account to
collateralise channels and pay gas. It does not need mnemonic recovery flows, human
authentication, or 831 lines of seed management — those belong to an end-user wallet, and
`toon-client` is where end users are served.

The fraud rules are removed because they guess at invariants the protocol now enforces.
Double-spend detection is subsumed by the nonce watermark, which rejects any claim that does
not advance. Balance manipulation is subsumed by verifying the signature over the balance
proof. What remains — rapid channel closure, unusual settlement amounts, traffic spikes — are
observations about counterparties rather than defences, and belong to whatever watches the
network rather than to the thing forwarding packets.

## Consequences

Audit stops being a bespoke subsystem. Under ADR 0008 every operator write carries an RFC 9421
signature, and retaining that signature is a stronger audit record than a log line asserting
that something happened — it is non-repudiable and names a key.

Rate limiting moves to the edge, where the identity being limited is known, rather than living
as two implementations in two directories.

Custody remains a real blast radius: a compromised connector can sign claims up to existing
channel collateral, and can spend from the treasury. Keeping the treasury in-process was
chosen over an external funder because a connector that cannot open a channel cannot peer
without human intervention, which defeats the point of leased routes and an automated
controller.

> **The treasury component this ADR names never shipped, and is now removed.** Issue #556
> (2026-08) deleted `connector-signer::treasury` (`Treasury`, `ChainClient`,
> `TreasuryError` and the rest) outright: outside its own `#[cfg(test)]` module, it had
> exactly two references in the entire workspace — a `pub use` and a doc comment — and no
> caller on any running node in the Rust connector's life. The collateral job this section
> describes is done, and has been since #559/#542, by `connector-settlement`'s
> `SettlementBackend` (`fund`/`redeem`/`channel_state`), constructed in
> `connector-cli::runtime` and integration-tested against a real chain. Keeping an unwired
> second implementation of the same concern is exactly the "undocumented, unjustified
> machinery" [ADR 0033](0033-the-exposure-machinery-is-retired-not-restated.md) was written
> to stop accumulating, and that ADR's precedent — remove a component whose job is already
> done elsewhere, rather than restate it — is the one applied here. The **signer** half of
> this ADR's title is unaffected: `connector-signer::Signer`/`LocalSigner`/`KmsSigner` are
> unchanged.