Skip to content

Feature/transaction evidence - #33

Closed
EugeneDymo wants to merge 30 commits into
mainfrom
feature/transaction-evidence
Closed

Feature/transaction evidence#33
EugeneDymo wants to merge 30 commits into
mainfrom
feature/transaction-evidence

Conversation

@EugeneDymo

Copy link
Copy Markdown
Collaborator

No description provided.

EugeneDymo and others added 2 commits August 13, 2026 20:24
The Exchange persists an append-once evidence row per executed
transaction, but no transport surface exposes it. Add the read RPC on
AdminService (the admin plane is proto-defined per ADR-022 D1):

- Request {ver, transaction_id}; response {ver, evidence,
  transaction_state, obligation_state} with field-level protovalidate
  rules only, matching the store's own CHECKs (128-hex signatures,
  32-byte Ed25519 keys, 253-byte domain bound).
- TransactionEvidence mirrors the evidence store column-for-column. Two
  fields are deliberately NOT named after their columns: offer_sig /
  offer_sig_algorithm follow ramp.v1.AgentAcceptancePayload.offer_sig
  (the same value), because the column names collide with identifiers
  the doc-conformance gate retired with the old scalar execute shape.
- TransactionState carries the thin transaction-log facts (per-item
  idempotency key, expiry, signed-URL hash); no status field — a denied
  execute writes no evidence, so existence is the status.
- ReportingObligationState + ObligationState enum carry the latest
  obligation (optional: the store allows several or none per
  transaction).
- File banner/service comment updated: the plane is no longer
  setters-only, and the no-idempotency-key reasoning now covers a
  side-effect-free read.
- corpusgen: seed the three new payloads (bytes rules and required
  Timestamps are outside auto-fill's reach).
- Docs: proto-admin.mdx gains the evidence sections; stale "both RPCs
  are full-replace overwrites" intro reworded.

Jira: RAMP-239

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Proto (ramp.admin.v1, all pre-release on this branch):
- remove signed_url_full: the evidence row is hash-only by design — the full
  signed URL is a live bearer capability on a plane with no request signing;
  the delivery join stays on TransactionState.signed_url_hash (fields 18-20
  renumbered to 17-19)
- drop max_bytes=253 from requester_domain: the row states signed bytes
  verbatim, and a bound could make it fail validation for a transaction that
  legitimately executed
- ObligationState.state: {defined_only, not_in:[0]} like every other
  server-output enum
- pin "EdDSA" as the content-signature label in both *_sig_algorithm comments
  ("ed25519" is the RFC 9421 request-signature label)
- normative MUST: transaction ids carry UUIDv4-class entropy (stated on
  ramp.v1.TransactionResultItem.transaction_id and in the admin trust banner);
  the reference implementation mints uuid.NewString()

Conformance:
- corpusgen: edges for bytes.len / bytes.min_len / string.max_bytes (with a
  multibyte mutant pinning byte-vs-character semantics) and repeated.min_items;
  a constrained field yielding zero edges is now a fatal generation error;
  honest not_in_zero_omitted label where protojson omits the zero enum;
  seeds say "EdDSA"
- corpus_coverage_test: class 6 guards the bytes/max_bytes rule shapes,
  derived from the schema so a dropped rule disarms its own guard
- samerule_test (new): "Same rule as <fq.field>" comment directives are
  resolved against the descriptor and must be proto.Equal — the drift gate
  for rules restated from ramp.v1; the four known copies carry directives

SDK types export:
- requiredgen: bytes.min_len>=1 / bytes.len>0 / repeated.min_items>=1 are
  wire-required, so the generated Pydantic/Zod reject omission like Go;
  loud panic on untranslatable string byte-length rules
- bytesgen (new) + merge_schema: exact-length bytes fields tighten to the
  exact base64 encodings (^[A-Za-z0-9+/]{43}=?$ for len=32), closing the
  33-byte window the old 43..44 range admitted
- merge_schema: build fails if any default violates its field's own
  constraints (the optional-with-invalid-default class cannot recur)

Docs: proto-admin.mdx follows the field removal and sibling dash style.

Regenerated: gen/, descriptor.binpb, corpus (273 cases), Pydantic/Zod types.
Gates: go build/vet/test, Python parity 351, TS parity 300, canonical
round-trip, doc-conformance, parity matrix — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noxlesh

noxlesh commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Code review — diff-scoped, RAMP-239

Multi-agent review of this PR's diff against main (base 702806c3, head 6afc5853), narrowed to RAMP-239's intent. Ten reviewers over the ten in-scope files; generated output under gen/go, gen/*/wire/{models.py,schemas.ts} and conformance/corpus/ was excluded as the drift gates own it, though it was read to judge what the generators now emit.

RAMP-239's acceptance criteria 2-4 (the README section, docs/HANDOFF-aws-demo.md, make quality) belong to reference-implementation, so on-task here means the GetTransactionEvidence contract: the admin messages and their constraints, the cross-language coverage, and the reference page. Nothing below asks for a README change.

What was run on this head: go test ./conformance/... passes, go run ./conformance/corpusgen produces no drift, ./scripts/check-doc-conformance.sh is clean, python3 scripts/gen-parity-matrix.py --check passes. The gen/ drift gate was not run — it needs buf 1.66.1 and the machine has 1.71.0 — and no item below depends on it.

Should fix

1. The offline verification recipe checks the row against keys the row itself carries.
TransactionEvidence's comment states the recipe as
ed25519.Verify(exchange_signing_public_key, offer_canonical_bytes, hex-decoded offer_sig)
and the matching call for the agent side (proto/ramp/admin/v1/admin.proto:189-196,
repeated in website/src/content/docs/reference/proto-admin.mdx:69). Every input
to both checks comes from the same message. A reader who follows the recipe
proves the three values agree with each other. It does not prove that either key
belongs to the Exchange or to the agent. Anyone who can rewrite the response or
the evidence store builds a different offer, signs its canonical bytes with a
freshly generated key, writes that public key into
exchange_signing_public_key, and both documented checks return true. This
plane has no request signing and no response signing, so the banner's mitigation
restricts who can call the RPC, not who can rewrite its answer. The ticket's
goal — that tampering at any layer breaks the assertions — does not hold for
this case. The anchoring step already exists in the protocol: a key is
identified by its RFC 7638 thumbprint, computable from the raw 32 bytes the row
carries (proto/ramp/v1/ramp.proto:51), and the Exchange publishes a
revoked-thumbprint list (ramp.v1.KeyRevocationList, proto/ramp/v1/ramp.proto:2693).
Extend the recipe in the proto comment and in the reference page: a verifier MUST
check the thumbprint of exchange_signing_public_key against the Exchange's
published key set and its revocation list, and MUST do the same for
agent_public_key against the directory named in agent_discovery_url. Say
plainly that without that step the two checks prove internal consistency only.

2. The recipe has no executable test.
The proto states an exact verification procedure, and nothing in the repository
runs it. conformance/corpus/cases.json carries 38 vectors for
TransactionEvidence, and every one of them is a field-rule mutant with filler
key material (strings.Repeat("k", 32), conformance/corpusgen/main.go:122
and :129) —
no vector holds a real signature over real canonical bytes. So the claim that
this message re-verifies offline is stated in the contract and proven nowhere.
Add a conformance test that builds an evidence row with a real Ed25519 keypair,
signs the canonical bytes, asserts both verifications pass, then flips one byte
of offer_canonical_bytes and asserts the verification fails. The negative half
is what pins the tamper claim.

3. Three protocol rules are stated on the read plane instead of on the ramp.v1 fields that mint the values.
TransactionEvidence.offer_sig and .agent_acceptance_signature require
^[0-9A-Fa-f]{128}$ (proto/ramp/admin/v1/admin.proto:236 and :255), and
TransactionEvidence.transaction_id requires {min_len: 1, max_len: 255}
(:210). On the agent plane the same values carry nothing comparable:
ramp.v1.Offer.signature has no rule (proto/ramp/v1/ramp.proto:544),
ramp.v1.AgentAcceptance.signature has only min_len = 1 (:1724),
ramp.v1.AgentAcceptancePayload.offer_sig has no rule (:1751), and none of
the three transaction_id fields in ramp.proto (:1875, :2199, :2836)
has one. Two things follow. The generated Pydantic and Zod types accept a
malformed signature on the plane where the value arrives from an untrusted
caller, and refuse it only when it is read back as evidence. And a transaction
whose id is 300 characters long satisfies the whole agent plane while its own
evidence row fails the generated schema — the forensic read breaks for exactly
the transaction an operator wants to investigate. The branch applies the
opposite principle two fields away: requester_id and requester_domain are
left unbounded, with the reason spelled out at admin.proto:277-281 — a bound
the agent plane does not enforce can make the row fail its own validation for a
transaction that legitimately executed. State each rule once on the ramp.v1
field the value enters through, then restate it on the admin field with the
Same rule as <field> directive so the new gate covers the pair, and regenerate
the corpus so all three languages refuse a malformed value at ingest. One cost
to plan for: the doc examples carry placeholder signatures such as
"base64-ed25519-credit-offer-sig..."
(website/src/content/docs/protocol/walkthrough-due-diligence.mdx:252) and
conformance/docexamples_validate_test.go validates them, so they need real
128-character hex placeholders in the same change.

4. The transaction-id entropy requirement is enforced in no layer.
This branch makes transaction_id the only selector for the evidence read and
adds the requirement as prose: ids MUST carry UUIDv4-class entropy
(proto/ramp/v1/ramp.proto:1869-1874), with the admin banner restating that
enumeration resistance depends on it (proto/ramp/admin/v1/admin.proto:19-26).
The field carries no protovalidate rule, so transaction_id: "1" is a valid
RAMP id and a valid evidence request in Go, in Python and in TypeScript. What
that buys an attacker is worth stating exactly: the request carries only
{ver, transaction_id} with no tenant scope, the plane is deployment-scoped
across tenants (admin.proto:219-220), and there is no per-operator identity in
v1. An Exchange that mints sequential ids therefore hands anyone who reaches the
listener a walk over every tenant's evidence — full signed offers, both
signatures, both public keys, requester identity and domain. Put the rule in the
schema: (buf.validate.field).string.uuid = true on
ramp.v1.TransactionResultItem.transaction_id, which pins what the reference
implementation already does, then update the corpusgen seed to a real UUID and
commit the regenerated corpus so Python and TypeScript inherit the refusal. If
non-UUID ids must stay legal across Exchange implementations, then the banner
has to say that nothing enforces the entropy requirement and that the network
boundary is the only enforced control — the wording claims a control the
contract does not have.

5. The response cannot carry the signed-URL signature assertion the ticket asks for.
The ticket's chain has two cross-party signature comparisons: the offer
signature, and the signed-URL signature against the prefix the edge logs.
TransactionState carries signed_url_hash, the sha256 of the whole URL
(proto/ramp/admin/v1/admin.proto:347), and no signature, so the second
comparison has no input. A sha256 of the full URL is not a prefix of the URL's
signature, so a client of this contract cannot reproduce that row. The comment
at admin.proto:300 argues that the full URL is a live bearer capability until
expiry, which is a sound reason to leave the URL out; it does not cover leaving
the signature out, and a detached signature on its own is not a retrieval
capability. Add the stored signature to TransactionState with the same hex
rule the offer signature ends up carrying. If the deployed edge does log a
url_hash and the hash join is the intended replacement for the prefix
comparison, say that in the signed_url_hash comment, because the contract is
then asserting something about the edge's log format that nothing in this repo
states.

6. bytes.min_len keeps protoschema's loose translation while bytes.len is tightened.
The branch adds conformance/bytesgen and tighten_bytes_len to turn
bytes.len = N into an exact base64 pattern, and that part works. The same
branch adds two fields with bytes.min_len = 1
TransactionEvidence.offer_canonical_bytes (proto/ramp/admin/v1/admin.proto:229)
and .agent_acceptance_canonical_bytes (:258) — which keep the untranslated
form: z.string().regex(/^[A-Za-z0-9+\/]*={0,2}$/).min(2) in TypeScript, and
constr(pattern=r'^[A-Za-z0-9+/]*={0,2}$', min_length=2) in Python. The string
"==" is two characters, matches the pattern, passes the minimum, and carries
zero bytes. Go's protojson refuses to parse it — verified:
invalid value for bytes field offerCanonicalBytes: "==" — so both generated
clients accept a value the server cannot even decode. The root is that
exactLen reads only b.Len (conformance/bytesgen/main.go:64) and
tighten_bytes_len skips a field it cannot match without a word. Cover
bytes.min_len in the same manifest, or fail the run on a bytes rule shape the
pipeline cannot translate — which is the convention this same branch establishes
for string.min_bytes in conformance/requiredgen/main.go:92.

7. The tightened pattern rejects the base64url alphabet that protojson accepts.
tighten_bytes_len hardcodes the standard alphabet,
^[A-Za-z0-9+/]{43}=?$ (scripts/sdk-types/merge_schema.py:308), and that
pattern reaches both clients for agent_public_key,
exchange_signing_public_key and signed_url_hash. Go's protojson accepts
either alphabet for a bytes field and switches to base64.URLEncoding as soon
as the value contains - or _. Verified with a probe against the generated Go
type: all four spellings of a 32-byte key — standard and URL-safe, padded and
unpadded — parse and decode to 32 bytes, while the pattern matches only the two
standard forms. For random 32-byte key material the URL-safe spelling contains
- or _ roughly three times in four, so this is the common case rather than a
corner. One scoping point, because the reviewers split on it: the standard-only
alphabet is protoschema's default and every other bytes field in the contract
already carries it, so the restriction is not introduced here. What is new is
this function re-authoring the whole pattern for these three fields, and a
docstring that claims the rewrite pins "the EXACT encoded forms of N bytes"
while it enumerates two of four. Emit both alphabets as one alternation,
building the two arms from one loop so the length arithmetic stays written once,
and sweep the inherited patterns in the same pass. If rejecting base64url is
deliberate, say so in the docstring and drop the completeness wording.

8. Nothing tests what the new tightening emits, and its padding length is derived twice.
Inside tighten_bytes_len the rem branch chooses the padding regex, and line
310 re-derives the same padding length in a separate conditional
(chars + (2 if rem == 1 else (1 if rem == 2 else 0))). Nothing checks the two
agree. The corpus cannot cover the difference: corpusgen sets bytes through
protoreflect and protojson always emits the padded form, so every corpus value
for these three fields is 44 characters — verified, the set is
{(field, 44, ends-with-'='), (field, 44, no-'=')} with no 43-character entry.
Change pad = "=?" to pad = "=" and every test still passes, while the
clients then reject the unpadded 43-character key that Go accepts. Two of the
three remainder branches have no field to exercise them at all. Add a behavioral
test under gen/python/tests/ and its gen/ts/tests/ parallel, in the shape of
test_numeric_wire_forms.py: assert the padded and unpadded forms are both
accepted and the 44-character unpadded form (33 bytes) is rejected, and state in
the docstring why the corpus cannot carry these cases. The deeper root is that
the base64 arithmetic is written in the Python bridge at all — bytesgen is the
authoritative view of the rule and could emit the exact encoded lengths, leaving
merge_schema.py to apply what it is told.

9. The new drift gate for restated rules is not armed for copies this branch adds.
conformance/samerule_test.go exists to keep an inline rule copy equal to its
source, and the branch uses it on four fields. Two copies it also adds are not
enrolled. TransactionEvidence.tenant_id restates {min_len: 1, max_len: 255}
(proto/ramp/admin/v1/admin.proto:214), the third identical tenant_id rule in
that file after :80 and :101, with no directive and no entry in
knownCopies. And agent_acceptance_signature's directive points sideways at
ramp.admin.v1.TransactionEvidence.offer_sig (:254) rather than at a ramp.v1
field, because there is no rule in ramp.v1 to anchor to — which is item 3
seen from the gate's side. Add the directive to tenant_id and repoint the
signature copy once item 3 lands. The gate itself has a second gap worth
closing in the same pass: it can only find copies that opted in, so a detector
that reports two fields with byte-identical rules and no directive would arm it
for the next author automatically.

10. requiredgen's new byte-length guard cannot fire for a presence-tracked field.
The guard panics on a string.min_bytes or string.max_bytes rule and explains
why: protoschema renders those as a character count, so a multibyte value the Go
server rejects passes the generated clients
(conformance/requiredgen/main.go:92-93). It sits inside zeroRejected, below
two early returns: if fr.GetRequired() { return true } at line 73 and
if fd.HasPresence() { return false } at line 76. Neither presence nor an
explicit required has anything to do with the character-versus-byte problem,
and both shapes are common in this contract, so adding
optional string x = 1 [(buf.validate.field).string.max_bytes = 8] — or the
same rule beside required = true — passes the whole gate and ships the
divergence the guard was written to stop.
Move the check out of zeroRejected into its own scan over every contract
field — it is an assertion about the schema, not an answer to "is the zero value
invalid".

11. Three new pieces disagree about whether string.max_bytes may exist.
requiredgen panics on the rule. corpusgen grew two mutants for it, including
the multibyte one, and hasConstraint now counts max_bytes as a constraint
(conformance/corpusgen/main.go:643 and :781). The class 6 coverage test demands
a multibyte max_bytes mutant whenever the schema carries the rule
(conformance/corpus_coverage_test.go:262-266). No field in proto/ carries
max_bytes, so the panic wins and the other two never run: the mutants are
dead code and the coverage branch reports a check it never performs. Pick one
position. Either the rule stays forbidden until the pipeline can enforce a byte
count — then drop the mutants and the coverage branch — or implement the
byte-count refine in merge_schema.py, remove the panic, and add a field that
exercises the path.

12. The new zero-edge guard does not close the class its own comment names.
corpusgen now fails the run when a constrained field produces no edges, and
the comment says this is how the first bytes rules shipped uncovered
(conformance/corpusgen/main.go:191-196). The guard only sees fields that
already passed hasConstraint, and the bytes rules were invisible precisely
because hasConstraint did not recognise them — so the same class of miss still
passes silently. The residue the guard does catch is narrow: an int64.gt rule,
where hasConstraint returns true and the Int64Kind arm handles only gte.
Move the check to the boundary that matters: walk every field whose
protovalidate rules resolve to anything at all, and fail on one that
hasConstraint skips, so a rule shape the generator does not know is a build
failure instead of a silent absence.

13. Three new corpus cases are labelled as length boundaries while the JSON they emit omits the field.
TransactionEvidence/offer_canonical_bytes/too_short,
.../agent_acceptance_canonical_bytes/too_short and
TransactionRequest/items/too_few all carry a name that says "a value below the
floor". Verified against the committed corpus: the first two emit an object with
that key absent, and the third emits {"idempotency_key": ...} with no items
key. proto-JSON has no present-but-empty form for either shape, so the case
pins "omitted is rejected", and both clients reject the omission for a different
reason — the field is required. The two labels are written at
conformance/corpusgen/main.go:677 and :713. The branch introduces the fix for
exactly this in the same file: not_in_zero_omitted for a zero-valued enum
(conformance/corpusgen/main.go:570-578), visible in the corpus as
ReportingObligationState/state/not_in_zero_omitted. Apply the same naming to
the bytes and repeated edges it adds, so the case id states what the case
actually tests.

14. The new wire surface is recorded in neither changelog.
The branch adds a service method, five messages and one enum to ramp.admin.v1,
plus a new normative MUST on ramp.v1.TransactionResultItem.transaction_id.
proto/CHANGELOG.md and its website mirror
website/src/content/docs/reference/changelog.mdx are untouched. Both kinds of
change have precedent in the file that was not touched:
proto/CHANGELOG.md:378 is the entry for the ramp.admin.v1 package itself,
and the first Unreleased entry is a comment-only change classified
"(no wire change; conformance-affecting)" — the exact class the entropy
requirement falls into. A consumer re-pinning this tree learns about a new RPC
and a new MUST only by reading the proto. Add two Unreleased entries and copy
them into the website mirror, which carries every entry today. The trust-model
decisions this branch makes — a read keyed by transaction id alone, hash-only
delivery join, public keys travelling in the row instead of key ids — belong in
docs/design-history.md for the same reason.

15. The "always EdDSA" invariant is carried by comments, not by the schema.
offer_sig_algorithm and agent_acceptance_signature_algorithm are documented
as server-derived with exactly one legal value, and the comment explains why the
label must not be echoed from the wire: an echoed label sits outside signature
coverage and could claim anything (proto/ramp/admin/v1/admin.proto:245,
:262). The rule that reaches the generated clients is min_len = 1, so
"RS256" and "none" validate in all three languages, and a consumer that
reads the label to choose a verification routine gets nothing from the schema.
The same claim is now written in five comments and six hand-written SDK
constants, and the branch adds a three-line comment in the corpusgen seed to
explain which of two labels belongs here
(conformance/corpusgen/main.go:117-121). Express the value once:
(buf.validate.field).string.const = "EdDSA" on both fields. That needs one
matching change in corpusgenhasConstraint does not recognise const or
in, so the fields would otherwise drop out of constrained and reach the
corpus with no mutant, which is the blind spot item 12 is about.

16. The request_id / request_id_minted pairing can be violated in every language.
Both fields are optional with no rule, and the comment states the invariant in
prose: both absent or both present is a store-level constraint the server
guarantees, field-level rules cannot express the pairing, and this file carries
no message-level CEL (proto/ramp/admin/v1/admin.proto:308-322). So the one
shape the pair must never take — request_id present,
request_id_minted absent — validates in Go, in Pydantic and in Zod, and the
corpus has no vector for the pair at all. The comment two lines below says why
that shape is harmful: the flag is what separates a server-minted correlation id
from a caller-supplied one, and an absent bool reads as false to a renderer,
which is the caller-influenced answer. The claim that field-level rules cannot
express the pairing is true; the conclusion that nothing can is not. Make it
structural — one optional sub-message holding both fields, with min_len = 1 on
the id — so absent means "no correlation id was recorded" and present means both
facts are there. That keeps the file's field-level-only policy, reaches the
generated types, and gives corpusgen something to mutate.

17. bytesgen is the third copy of the manifest-generator program, and the collision guard is in only one.
requiredgen, uniquegen and now bytesgen share the same body: resolve field
rules over conformance.EachMessage, key a map by bare message name, write
indented JSON. Only requiredgen calls conformance.AssertUniqueBareNames(),
and its comment explains that the manifest keys by bare name so a cross-package
duplicate would silently overwrite. go run ./conformance/bytesgen out.json has
no such guard, and merge_schema.py would then apply the tightening to the
wrong message. exactLen also treats a rule-resolution error as "this field
has no exact length" (conformance/bytesgen/main.go:60-61), so a resolver
failure removes a field from the manifest instead of failing the run. Extract
the shared body into one helper in conformance/ that all three call, with the
collision assertion and the error handling in it once.

18. merge_schema.py's argument slice hardcodes the count.
main(*sys.argv[1:7]) (scripts/sdk-types/merge_schema.py:424) had to widen
from [1:6] in step with the new parameter and the new argument in
scripts/gen-sdk-types.sh:55. Three places must agree and the failure is silent
both ways: every manifest parameter defaults to None and each pass is guarded
by if <path>:, so a dropped or extra argument disables a tightening pass and
the script still exits 0 after printing its success line. The loose base64
window would then ship from a run where every check passed. Use
main(*sys.argv[1:]) so a count mismatch raises TypeError and fails the
pipeline under set -euo pipefail.

19. The class 6 coverage guard is looser than it reads, and its own comment is stale.
Two defects in the new guard. The multibyte scan walks every string in the case
JSON and returns on the first value whose byte length exceeds its character
length (conformance/corpus_coverage_test.go:265-266), so a multibyte value in
any unrelated field satisfies an assertion about the field under test. Anchor
the scan to the field the case mutates. And the class count is stated in two
places: the file header was edited from "These five classes" to "These classes"
(:6), while TestCorpusCoverage's own comment still says "the five
field-level rule classes" (:114) after a sixth was added. Make it name the
classes the test actually has.

20. The new gate compares rules but ignores presence, and reports a resolution failure as drift.
samerule_test.go compares two fields with proto.Equal over their resolved
rules. Presence decides whether a rule bites at all: protovalidate skips an
unset presence-tracked field, so optional string x and plain string x with
byte-identical rules behave differently, and the gate calls them equal. Compare
HasPresence() alongside the rules, or state in the file comment that the gate
covers rule text only. Separately, when ResolveFieldRules fails on the source
field the error is discarded (conformance/samerule_test.go:73) and the
mismatch surfaces as "restated rule DRIFTED", which sends the next author
looking for a rule change that did not happen. Report the resolution failure as
itself.

21. The evidence row's largest fields have a floor and no ceiling.
offer_json, offer_canonical_bytes and agent_acceptance_canonical_bytes all
carry a non-empty minimum and no maximum
(proto/ramp/admin/v1/admin.proto:225, :229, :258), while every other
string in the file is bounded at 255. A response is therefore unbounded in size
for a reader that trusts the schema, and the generated Pydantic and Zod types
will materialize whatever arrives. Bound them at the largest offer the contract
allows, or state in the comment why an unbounded row is intended.

Notes

  • conformance/samerule_test.go is the right mechanism for a rule the toolchain
    forces to be an inline copy, and the corpus coverage classes are the right
    place to pin a rule shape the generator once skipped. Both of the items above
    that touch them are about arming them further, not about the approach.
  • Outside the ticket, on lines this branch changed: the new corpusgen seed block
    is not gofmt-clean (conformance/corpusgen/main.go:107; the file was clean at
    702806c3, and the gate has no gofmt step, so the next author's editor will
    mix a formatting diff into their change), and stringValues is the third
    structurally identical recursive JSON walker in
    conformance/corpus_coverage_test.go. Both are real and both are in this
    branch's own new code; they are recorded here rather than above because they
    do not touch the evidence contract.
  • On unchanged lines, so not this branch's doing: every other bytes field in the
    contract carries protoschema's standard-alphabet-only pattern, so item 7's
    fix has siblings to sweep; ramp.v1.Offer.signature_algorithm calls the value
    a JWS algorithm at proto/ramp/v1/ramp.proto:547 while :1719 and
    sdk/go/helpers/offer.go:38 make the signature a bare hex-encoded detached
    Ed25519 value; conformance/reachability_test.go was already not gofmt-clean
    at the base commit; and requiredgen.zeroRejected inspects only
    GetGreaterThan(), so an integer rule with an upper bound that excludes zero
    keeps its default: 0 and the new assert_defaults_valid guard skips it
    because the node is not a string.

EugeneDymo and others added 27 commits August 14, 2026 10:33
GetTransactionEvidence now selects by the (tenant_id, transaction_id)
pair: transaction ids legitimately circulate to counterparty agents, so
the id alone must not act as a bearer capability for the forensic row,
and a request that names the tenant is what makes a per-tenant ACL
possible on this plane. A tenant mismatch is NOT_FOUND, byte-identical
to an unknown id. The banner, ramp.proto entropy note, and docs now
describe the pair selector.

Also applies the 140826_0917 code-review fixes:
- requiredgen: byte-length guard moved to its own EachMessage sweep so
  required/HasPresence early returns cannot bypass it (MED-02)
- corpusgen: assertRulesClassified dies on any rule member edges() does
  not classify — the shape allowlist now fails closed (MED-04); rules()
  dies on resolve errors
- corpusgen: omission-collapsing mutants relabeled honestly
  (too_short_omitted / too_few_omitted) and the explicit-empty wire
  shape ("" / []) emitted as its own case via a JSON-layer patch, so
  both client parse paths are pinned (MED-03)
- drift gate armed for the tenant_id rule copies in TransactionEvidence,
  ReportingPolicy, and the new request selector (MED-05)
- bytesgen: AssertUniqueBareNames guard added; ResolveFieldRules errors
  no longer swallowed (MED-06)
- samerule_test: ResolveFieldRules errors handled by name (LOW-01)
- merge_schema.py: tighten_bytes_len fails the build on manifest entries
  with no matching schema property (LOW-02)

Corpus: 295 cases (+3 for the new selector field, relabels throughout);
Python 373 / Zod 322 parity tests, canonical round-trip, doc
conformance, and the drift gates all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkL4QJ1evGVntW4TcfYxwV
…, min_len translation, executable verify recipe

The remaining review findings on the transaction-evidence contract, in one
batch (beads ramp-wr6/hfh/7e7/uo5/2ek/7cl/y7x/0i7/5fn/jil/jb5):

Generated clients (no wire change):
- bytes.min_len now reaches the Pydantic/Zod export: the pattern requires the
  encoded payload chars of at least N bytes before the padding tail, so "=="
  (zero bytes, which Go protojson refuses) no longer passes the clients; the
  bytesgen manifest carries {len}/{min_len} rule objects and fails closed on
  any bytes rule shape merge_schema cannot translate.
- New behavioral tests (gen/python/tests + gen/ts/tests) pin the base64 forms
  the corpus structurally cannot show: unpadded/padded 32-byte keys in both
  alphabets accepted; 33-byte unpadded and 31-byte padded 44-char forms
  rejected; the min_len padding edge cases. Padding arithmetic in
  merge_schema is derived once and reused for pattern and maxLength.
- merge_schema main() takes sys.argv[1:] verbatim, so an argument-count
  mismatch with gen-sdk-types.sh raises instead of silently disabling a
  tightening pass. uniquegen gains the AssertUniqueBareNames guard.

Conformance:
- New evidence_offline_verify_test.go executes the TransactionEvidence
  comment's recipe with real Ed25519 keys — both verifications pass on a
  contract-valid row, and a flipped canonical byte fails the matching side.
- samerule gate also compares HasPresence(): byte-identical rules on fields
  with different presence modes diverge at runtime on omission.
- corpus_coverage: class comments un-staled; the multibyte max_bytes scan is
  anchored to the mutated field. corpusgen's max_bytes support is documented
  as forward-provisioned while requiredgen forbids the rule.

Contract comments (proto + docs mirrors, gen/ regenerated):
- TRUST BOUNDARY adds the RFC 7638 thumbprint check against
  ramp.v1.KeyRevocationList (WBAFile.revocation_url).
- offer_json and both canonical-bytes fields state why they are unbounded;
  agent_acceptance_signature explains its intra-package drift anchor.

Also includes the branch's staged evidence-read finalization (changelog,
design history, website pages) that predates this batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	conformance/corpus/cases.json
#	gen/descriptor.binpb
#	gen/ts/wire/schemas.ts
#	proto/CHANGELOG.md
#	website/src/content/docs/reference/changelog.mdx
…eft behind

The generated artifacts for epic ramp-3ob (beads ramp-3ob.1–.7) landed with the
main merge in 544a338, because resolving that merge meant staging the
regenerated outputs. The sources that produce them stayed uncommitted, so CI
regenerated from the old proto and the drift gate reported gen/descriptor.binpb
and gen/go/ramp/admin/v1/admin.pb.go. Nothing generated changes here: a clean
regenerate from these sources reproduces the committed artifacts byte-for-byte.

Generated clients (no wire change):
- tighten_bytes_len now emits an alternation of pure standard and pure url-safe
  base64 branches instead of one merged [A-Za-z0-9+/_-] class, and derives the
  padding from the payload length mod 4 instead of a free ={0,2} tail. Applied
  to the bytes.len branch as well as bytes.min_len. Mixed-alphabet values,
  "AA=", "AAA==" and "AAAAA" are rejected as Go rejects them. No lookahead is
  used: Pydantic v2 compiles patterns with the Rust regex engine, which has
  none, so each accepted payload residue (p%4 in {0,2,3}) gets its own branch.
  Verified against Go before pinning — all 37449 strings up to length 5 over
  "Aa0+/-_=" plus 612 targeted 41-45 char variants for len=32 decide identically
  under protojson and under the generated pattern.

Conformance:
- The base64 truth table moves to conformance/testdata/bytes_wire_forms.json,
  loaded by both the Pydantic and Zod suites, and conformance/
  bytes_wire_forms_test.go pins every row against Go protojson + protovalidate.
  The table now states the server's verdict instead of a belief about it, and
  each shared base must itself be a valid message.
- conformance.FieldRules / EachRuledField own one rule-resolution policy: a
  resolver error panics rather than reading as "no rules". Every hand-rolled
  walk-and-resolve loop goes through it (bytesgen, requiredgen, uniquegen,
  corpusgen, samerule, corpus_coverage, descriptor_invariants, validate, and the
  domain-constraint guard main added), so schemaRuleShapes can no longer disarm
  the class-6 coverage guard silently.
- corpusgen hasConstraint is derived from classifiedRuleMembers /
  classifiedItemMembers instead of restating them: a repeated field whose only
  rules were item lengths passed classification and then got zero corpus cases.
  The corpus regenerates byte-identical.
- bytesgen fails closed on len+min_len set together and on a zero-valued length
  rule, and reads length rules by value — the reading corpusgen, requiredgen and
  the coverage guard already used.
- The class-6 multibyte guard resolves the field's real max_bytes limit and
  requires a mutant over it in BYTES while within it in characters.

Contract text:
- admin.proto transaction_id no longer claims a documented 36-char UUID scheme.
  ramp.v1 documents transaction ids as implementation-defined with a 26-char
  ULID, and proto comments ship verbatim into the generated SDK descriptions.
- CHANGELOG: corpus counts corrected — 208 to 549 for the recipient-addressing
  revision, 636 after the evidence read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNfM4UU2ixdphTEyrck4yS
…o (RAMP-239)

Two problems with one root cause: rule RESOLUTION was centralized in
contract.go, but rule-SHAPE inspection stayed copied into each generator.

"Does this field carry a bytes length rule" was read four different ways —
by value in bytesgen, by presence in corpusgen, and two different mixes in
requiredgen and the class-6 coverage guard. The same field could therefore
be ruled for one consumer and unruled for the next. contract.go now owns
the question: BytesLength reads it by PRESENCE and rejects the two shapes
that make presence and value disagree — len and min_len set together, and
an explicitly zero value. MustBytesLength applies the package's existing
fail-closed panic policy. All four call sites consume it.

The two fail-closed guards added on this branch read only the top-level
rule oneof, so a repeated.items.* rule walked straight past them — a shape
the contract already uses at admin.proto ReportingPolicy.required_fields
and five sites in ramp.proto. RuleSets/EachRuleSet descend one level, and
requiredgen's string byte-length guard now runs on the wider sweep through
the shared StringByteLength predicate. RuleSets is a plain function over a
FieldRules, not part of the descriptor walk, so a test can feed it a
synthetic rule set and prove the descent still happens.

A zero-valued length rule used to surface from ci-local.sh as "strings:
negative Repeat count" out of corpusgen's bytesOf(-1), naming neither the
field nor the rule — corpusgen runs long before the sdk-types block where
bytesgen's diagnostic lived. It now dies in the shared accessor with the
field's name, and descriptor_invariants_test.go carries INV-4 so `go test
./conformance` reports every offender rather than stopping at the first.

Also deletes the class-6 string.max_bytes coverage branch and the four
helpers only it used. requiredgen's assertNoStringByteLengthRules panics
on any string byte-length rule, contract-wide, so the rule cannot be
committed and no corpus case for it can ever exist — the branch returned
early forever and its helpers were never called by any test. One of them,
fieldStringValues, was already wrong (it matched a leaf name at any depth
instead of anchoring to the mutated field). corpusgen keeps its
forward-provisioned max_bytes mutants; only their comment changed, since
it pointed at a guard that no longer exists.

required_fields.json and bytes_len.json are byte-identical before and
after, and the corpus does not drift.

Closes ramp-h4a, ramp-a3f (review 140826_1645-diff HIGH-02/03/04).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ci6XyfVXH4bPZSRifBXP5c
…mp.v1 (RAMP-239)

All the renamed fields are new on this branch, so the renames are free now
and would be breaking later.

ReportingObligationState anchored created_at to the store's CreatedAt but
silently renamed the other two columns, and received_at's stated meaning
was broader than the column behind it. deadline is now window_end and
received_at is now fulfilled_at, matching the store's WindowEnd and
FulfilledAt, so a reader joins this record against the storage model by
name with no translation step. fulfilled_at also says what the column
means: a report that was ACCEPTED, the same event that moves state to
OBLIGATION_STATE_FULFILLED — not merely a report that arrived. A report
that arrived and was rejected leaves it absent and the obligation still
expires on window_end. consumed_quantity is called out as the one field
with no column behind it.

agent_discovery_url is now agent_directory_url. ramp.v1 uses "discovery"
for RESOURCE discovery (DiscoveryRequest, OfferGroup.discovery_method), a
different thing entirely, while every sentence describing this field — in
the proto, the trust-boundary block, the threat model and the reference
page — already called it a directory.

offer_sig_algorithm is deliberately NOT renamed to the
signature_algorithm spelling ramp.v1 and its own sibling
agent_acceptance_signature_algorithm use. Two reasons, both now stated in
the comment: the label names the neighbouring offer_sig, which copies an
upstream field name verbatim, so a label that renamed the field it
describes would be the worse inconsistency; and the long spelling is
unavailable, because scripts/check-doc-conformance.sh bans that identifier
across the protos and the docs so the scalar field RAMP-103 retired cannot
be read as live anywhere. ramp-d8h tracks the denylist entry itself.

gen/, the validation corpus and the Pydantic/Zod export are regenerated.

Closes ramp-98e (review 140826_1645-diff MED-13/14/15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ci6XyfVXH4bPZSRifBXP5c
Brings in the audience/domain work (#35): the SDK-side bare-domain rule,
the Go/Python/TS host helpers and their shared audience vectors, plus the
proto comment corrections that went with them.

Three conflicts, all resolved by regenerating rather than hand-merging,
because both sides changed generated artifacts:

  gen/descriptor.binpb, gen/ts/wire/schemas.ts — rebuilt from the merged
  proto (buf generate + buf build, then scripts/gen-sdk-types.sh). Both
  sides' content is present: main's ramp.v1 comment corrections and this
  branch's ramp.admin.v1 evidence plane.

One semantic conflict git could not see. main's new conformance/
domain_sdk_parity_test.go calls a local fieldRules helper that this branch
had already deleted from domain_constraint_test.go, when it moved rule
resolution into contract.go. The guard now calls the shared FieldRules,
which is where that call was heading anyway — and it inherits the stricter
error policy: the deleted helper read a RESOLVER ERROR as "no rules",
which would have reported this guard's rules as "vanished" and pointed a
reader at the wrong problem. FieldRules panics on a resolver error and
returns nil only for a genuinely unruled field.

Verified on the merged tree: buf lint, go build/vet/test, no corpus drift,
doc conformance, the SDK parity matrix, the website guards, 770 Pydantic
parity tests, 719 Zod parity tests and the canonical round-trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ci6XyfVXH4bPZSRifBXP5c
…s docs

TransactionState.expiry becomes signed_url_expiry. The same instant carried
four names across four planes: expires_at on the wire, url_expires_at in the
Exchange store, signed_url_expiry in the event log, and a bare expiry here.
This message states transaction-LOG facts and its neighbour signed_url_hash
already copies the event-log name exactly, so the log spelling is the one that
lets a reader join by name with no translation step. The field is new on this
branch, so the rename is free now and breaking later.

CLAIMS THAT WERE FALSE AGAINST THE TREE

The revocation step could not be performed. It told a verifier to check BOTH
keys against the Exchange's published list, but WBAFile.revocation_url is one
URL per DIRECTORY, so that list can only enumerate the Exchange's own revoked
keys and a revoked agent key can never appear in it. Each key is now checked
against its own side's list, reached the same way its anchor was, and the text
states that no single list covers both.

requester_domain's rationale said upstream places no wire constraint on either
value. Requester.domain now carries max_len 260 plus the bare-host pattern.
The field stays unbounded for the reason that actually holds: the agent-plane
rule governs what an Exchange may ACCEPT on the way in, not what this row may
STATE after the fact, and a rule here could make a row fail its own validation
for a transaction that legitimately executed.

The delivery join named a url_hash column that exists nowhere. It is
signed_url_hash. Both comments now also state that the two planes hold the
same SHA-256 digest in different forms, 32 raw bytes here against a text
string in the log, so a reconciliation query must normalize both sides before
comparing rather than trusting string equality.

The ObligationState comment described a rejection as a non-OK
UsageReportResponse. No such message is sent: a rejection travels as a
transport error carrying the typed detail, and no response exists on that path.

SCOPE OF THE GUARANTEE named request_id as a sibling of transaction_id. The
field is request_correlation; request_id is one level down inside it.

RequestCorrelation.request_id led with the SDK's verbatim passthrough, which
let a reader conclude nothing validates on write. It now leads with the
write-path rule and demotes the passthrough to background.

DOCUMENTATION

The Exchange storage model gains a Transaction Evidence section. The
append-once row was documented as a store nowhere, which left the
design-history carve-out for the persisted correlation id resting on a store
it could not name. The section documents the write rule, the append-once
constraint, and the request_id / request_id_minted pair that must be present
or absent together. It points at the proto for the column list instead of
copying it, so there is no second listing to drift.

The threat model described the nonconforming-correlation filter as read-time
while the proto and the storage model describe it as write-time. Settled on
write-time, with a residual line stating that the contract enforces neither
side and that one nonconforming stored value invalidates the entire evidence
response rather than degrading it.

exchange_signature named the Offer's signature field in 24 places across 14
documents. No such field exists in any proto; it is Offer.signature. Renamed,
and added to the doc-conformance denylist so it cannot return. The script now
also records why a generic descriptor-derived check on prose field names was
measured and rejected: 189 distinct backticked snake_case tokens in the docs
resolve to nothing in the contract, and nearly all are correct prose about
storage columns, HMAC parameters, RPC names and quoted external vocabularies.

Restored a published corpus figure that an earlier commit rewrote from 319 to
549, which attributed later growth to an entry predating it. Corrected this
revision's own attribution against a measurement: the corpus goes from 549 to
646, a net +97 from 122 added and 25 removed across 29 messages, of which 88
additions land on the evidence-read messages.

CONFORMANCE

TestBytesWireFormCoverageIsComplete derives the wire-form table's scope from
the descriptor rather than trusting the table to list itself. Base64 padding
and alphabet are the one axis the generated corpus cannot reach, so a new
bytes-length field would otherwise look covered while no row exercised it. It
fails in both directions and on a form-set whose rows were computed for a
different rule value, and refuses rather than skips a rule it cannot express.

The offline-verify test header claimed its negative half pins the tamper
property. It cannot: a genuine Ed25519 signature verifying and a tampered one
failing is crypto/ed25519 behaviour, not this repository's, so no change here
can turn those lines red. The header now separates the protovalidate call,
which is the real gate, from the ed25519 assertions, which are executable
documentation of the stated recipe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
… states

Four fields described a rule in their comments and enforced nothing. Two
transaction-log documents demanded columns that a whole delivery method cannot
produce, and left the join key between two stores unspecified on one side.

AGENT-PLANE SIGNATURES NOW CARRY THE HEX SHAPE THEY DESCRIBE

Offer.signature carried no rule at all and AgentAcceptance.signature carried
only min_len 1, while both comments described a detached Ed25519 signature in
hex. The read plane already enforced exactly that on its stored copies, so the
forensic copy of a signature was validated and the live one was not: a
malformed signature was accepted on the agent plane, failed verification
later, and only failed VALIDATION once it reached an evidence row it could
never legitimately reach.

Both fields now carry pattern ^[0-9A-Fa-f]{128}$ — 64 bytes of Ed25519
signature, hex-encoded, either case accepted because hex decoding accepts
both. On AgentAcceptance.signature the pattern replaces min_len 1, which it
subsumes exactly.

Breaking in the descriptor, but no conformant caller is refused. A value
outside this shape cannot hex-decode into 64 bytes, so it could never have
verified; the rejection moves from the verify step to the validation step,
earlier and with an error that names the problem. All three SDKs already emit
lowercase hex — the Go helpers via hex.EncodeToString, the Python core via
.hex(), the TypeScript signers by building hex a byte at a time.

Offer.signature also becomes mandatory in practice, since the empty string
does not match the pattern. That restates what the message already meant: an
offer whose terms, pricing and expiry are not signed is not an offer.

The Same rule as drift directives on the two admin fields re-anchor UPSTREAM
to the ramp.v1 fields. They previously anchored inside the admin package with
a comment explaining that ramp.v1 carried no signature-format rule to point
at. That is no longer true, and the gate now compares the two planes against
each other rather than the admin plane against itself.

TRANSACTION_ID IS ENFORCED NON-EMPTY WHERE IT IS THE DEDUPE NAMESPACE

UsageReport.transaction_id and DisputeRequest.transaction_id both said MUST be
non-empty in prose and were both bare strings, so an empty value passed. The
rule is not a shape preference. For these two RPCs the named transaction IS
the dedupe namespace for the idempotency_key beside it, so a message naming no
transaction has no namespace to dedupe within, and the namespace invariant —
one caller's key never collides with another caller's cached result — has
nothing holding it up. Both now carry min_len 1, with no upper bound, because
the Exchange assigns the id and nothing upstream constrains its length.

A DIRECT DELIVERY CANNOT FILL TWO COLUMNS THE LOG DEMANDED

The transaction-log event schema listed signed_url_hash and signed_url_expiry
as required, three lines above a delivery_method column that admits DIRECT.
A DELIVERY_METHOD_DIRECT transaction returns the resource inline or from the
Exchange's own endpoint. It mints no signed URL, so it has no hash and no
expiry to log.

Both columns are now conditional, using the notation the same table already
uses elsewhere rather than a new one. The event itself is still always
produced — it is the settlement record for every authorized transaction — and
the new section says so, because conditional columns otherwise read as no
event and a direct transaction would appear to vanish from the settlement
stream. The two columns are absent together or present together, and a store
must hold no value rather than an empty string or a zero timestamp, so the
Exchange storage model now declares them as pointers over nullable columns.
The operator plane already declared both fields optional; a value type in the
store would have made that optional unfillable.

Two stale cells in the same table went with it: delivery_method listed only
two of the three delivery methods, which is what made the required columns
look safe, and agent_identity_hash was described as bound to a URL, which is
false for a direct delivery.

THE JOIN KEY NOW HAS A STATED ENCODING ON BOTH SIDES

signed_url_hash was documented as a string holding the SHA-256 of the signed
URL, with no text encoding stated anywhere, while the reconciliation query
joins the CDN export and the Exchange export with plain string equality. That
is only correct if both sides independently chose the same spelling.

The column definition now states both halves. What is hashed is the full
issued URL including its query string, which is what the Go SDK's HashURL
already computes and names as this column's value. How it is written is
lowercase hex: the digest itself is 32 raw bytes on both the SDK and the
operator plane, so the text form is purely how a log renders it, and hex has
one spelling where base64 has three that all look plausible in a log.

The reconciliation query gained the step it needed. A CDN access log records
the fetched URL, not a digest, so that side is a column the provider derives —
and any other spelling misses every row, which reads as revenue leakage rather
than as a formatting mistake. The prose under that query also contradicted the
query itself, claiming the join works through an embedded transaction id; the
hash join establishes the match and the embedded id identifies which
transaction it belongs to.

The operator plane deliberately still does not pin the log's encoding. It
points at the transaction-log contract as the owner of that decision, and that
pointer now resolves to a statement instead of to silence.

FIXTURES AND EXAMPLES THE TIGHTENING TOUCHED

Twenty-one signature values inside validation-marked documentation fences were
truncated placeholders and could no longer validate. They are now full
128-character hex, one seed repeated per signature so two signatures in the
same example stay distinguishable, and the same value reused where the same
offer appears twice.

The corpus generator's Offer seed needed a signature and its DisputeRequest
seed a transaction id, because seeds bypass auto-fill entirely; its string
sample list gained a 128-character hex entry, appended, which the file's own
append-only rule guarantees cannot re-value any existing field. Corpus goes
from 646 to 668 cases.

Validation fixtures gained the two values as named constants beside the
existing example-host constant, which exists for exactly this purpose: a
fixture that leaves a newly ruled field empty fails on that field instead of
on the rule the fixture was written to exercise. The negative fixtures got
them too, so each fails for the one reason its name claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
The evidence-read contract described the store underneath it from the schema
rather than from the store. The Exchange-connected side answered with the
actual columns, and several of those descriptions were wrong. This corrects
them and, where the contract had specified a mechanism, restates the property
it actually depends on.

THE DIGEST IS RAW BYTES ON BOTH SIDES, SO THE JOIN NEEDS NO NORMALIZING

TransactionState.signed_url_hash and the delivery block on TransactionEvidence
both said the two planes hold the same SHA-256 digest in different forms — 32
raw bytes on the admin plane against a text string in the log — and concluded
that a reconciliation query must normalize both sides before comparing. The
log column is BYTEA with a 32-byte length check. Both sides are bytes and the
comparison is byte-to-byte.

An encoding only enters where a store is RENDERED as text: protojson base64s
the field, and a log export picks its own spelling. So it is exports, not
stores, that a join has to reconcile, and the transaction-log contract owns
that decision for its export. Lowercase hex moves from the column definition,
where there is nothing to encode, to the export, where there is.

SIGNED_URL_EXPIRY IS NAMED FOR ITS PAIR, NOT FOR A COLUMN

The rename on this branch argued the field states the transaction-log column
name so a ledger joins by name with no translation step. The log column is
named plainly `expiry`. The name stays — pairing with signed_url_hash is the
reason that survives, and signed_url_hash does match a real column — but the
comment no longer claims a name-join. A ledger matches this field by meaning.

THE CORRELATION RULE FIXES AN INVARIANT, NOT A MECHANISM

RequestCorrelation.request_id said a nonconforming X-Request-ID is recorded as
no correlation, with both columns null. That mandated one implementation of a
property the contract does not otherwise care about.

The property is that a persisted request_id always conforms, established on
the write path so a read is never a filter over a laxer stored value. Two
mechanisms reach it: reject the bad header and record a server-derived id in
its place, or record no correlation at all. Neither can put a nonconforming
value in the store, and the difference between them is invisible on the wire.
The reference Exchange does the first, and validates against a charset
strictly inside the contract's, so nothing it accepts can violate the wider
rule.

The minted flag widens with it, from "server-minted because the header was
absent" to "server-derived". True now covers both ways a server derives one —
no header, or a header replaced — because the property the flag exists for is
influence: false means a caller chose these characters, true means no caller
did. The narrow definition had encoded a mechanism into a flag.

THE CORRELATION ID DOES NOT JOIN TO THE DELIVERY LOG

Two places said it joins outward to the edge delivery log. The delivery fetch
is a separate HTTP request, made later by the agent to the CDN, and it does
not carry the execute request's X-Request-ID. It joins to whatever else
recorded the same id for that execute call — the Exchange's own request logs,
and any tracing pipeline that saw the header. The delivery-side join is
signed_url_hash, and both places now say so.

NULLABILITY IS CONDITIONAL ON WHAT AN EXCHANGE EMITS

The storage model asserted the two signed-URL columns are nullable. The
correct statement is narrower: an Exchange that can return a direct delivery
must permit absence, because it mints no URL and has nothing to hash or
expire; an Exchange that only ever mints one may declare both columns NOT NULL
and still conform. The operator plane accommodates both without a choice of
its own, since both fields are optional there.

An Exchange adding direct delivery later cannot simply write NULL into NOT
NULL columns. It needs a schema change and a contract decision this revision
deliberately does not make — whether a direct transaction produces a
signed-URL-issued event at all. That is now stated as an open question to
settle before implementing, rather than assumed either way.

THE TRANSACTION LOG IS NOT APPEND-ONLY, AND THE SHIPPED DDL SAID IT WAS

The storage model's Growth-tier DDL carried REVOKE UPDATE, DELETE on the
transaction log and called the table append-only. Transaction rows are updated
after they are written: the consumed quantity lands when the usage report
does. An implementer following that DDL would have blocked a write the
protocol requires. UPDATE is no longer revoked there, with the reason stated.

Append-once belongs to the evidence store alone, and the difference is the
point: the log follows a transaction as it changes, the evidence row states
what was agreed and can never legitimately change afterwards. DELETE stays
revoked on both — retention drops whole partitions, which needs no row-level
delete.

STORING THE FULL SIGNED URL IS AN OPT-IN, AND SAYING SO ONCE WAS NOT ENOUGH

The transaction-log page has always described full-URL storage as a
configuration option defaulting to off, with the hash alone in production. It
said that about one log. The posture belongs to every store that holds
delivery facts, because a signed URL is a live bearer capability until expiry
and the stores that hold it retain rows for thirteen months — minutes of
capability inside years of retained text. A column holding it must be nullable
and written only under the flag, wherever it appears.

That is separate from the structural rule that the operator plane's message
carries no URL field, which cannot be configured away. Both are now stated,
and stated as different kinds of rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
…sence

The Exchange side reviewed the evidence-read contract against its schema and
found a field with no source behind it. This moves that field where the fact
actually belongs, and lands the doc corrections the same review produced.

BROKER WAS A TRANSACTION-LOG PROJECTION WITH NO COLUMN

TransactionState.broker was a plain string whose comment described a
transaction-log column that no implementation has. TransactionState exists to
project log columns, and a field with nothing behind it breaks the property
that makes the projection meaningful.

Broker routing is not operational state anyway. It is an execute-time
observation about the connection the request arrived on, covered by neither
signature — the same category as request_correlation, which already sits on
TransactionEvidence. So it moves there, and TransactionState goes back to
every field being backed by a column.

The field is now optional, which turns two states into three: absent means the
Exchange does not record routing, '' means it does and the acceptance arrived
direct, a value means it arrived through that hop. Without explicit presence
the field defaults to '', so an Exchange with nothing to say would have stated
"arrived direct" for every row — a forensic plane asserting a transport fact
it never observed. A field that lies by default is worse than no field.

The value is defined as implementation-defined provenance for the outermost
hop, not a resolvable identity. A reference Exchange serves the verified
RFC 7638 key thumbprint of the hop that presented the request, and
deliberately does not resolve it to a directory host, because the relay hop is
not re-identified against a registry and the recipient's own relay-permission
setting is the gate. A reader may compare the value for equality against a
thumbprint it already holds, but must not expect a hostname.

THE DIGEST HAS THREE FORMS, NOT TWO

The previous revision said both stores hold the digest as raw bytes, so a join
needs no normalizing. True of the two Exchange stores and wrong as a general
claim: the edge delivery record holds it as lowercase hex. So a query joining
either Exchange store against a delivery record does cross an encoding
boundary, while the two Exchange stores join directly.

This also supplies the evidence the hex choice never had. Hex was originally
justified from the URL's own signature parameter, which came from a
verification page describing a scheme no Exchange deploys. The delivery record
uses hex for this exact field, so an export in hex matches the delivery side
with no conversion — which is the join reconciliation actually runs.

WHETHER THE FULL SIGNED URL MAY BE STORED DEPENDS ON THE SIGNING SCHEME

The previous revision said a column holding the full URL must be nullable and
written only under the flag, wherever it appears, because the URL is a live
bearer capability. That is only true for some schemes.

An agent-bound URL whose edge enforces proof of possession is not a capability
at rest: a fetch must be signed by the agent key whose thumbprint the URL
carries, so a reader of the store cannot fetch with it. A natively-verified
URL is a capability until it expires, because native verification cannot check
proof of possession. The rule now tracks that property instead of the artifact
— unconditional storage is allowed for the first, the flag governs the second
— and the flag should be settable per tenant or coarser, since the scheme is a
per-tenant property.

The reasoning for storing a preimage at all is stated with it, because the
choice is not reversible: the hash proves that a delivery record matches what
was minted but cannot show WHAT was minted, and the flag is not retroactive.
A preimage exists only for transactions executed while it was on, and a
dispute always concerns a past transaction, so the decision is made
permanently, per transaction, at execute time. Stripping the signature
parameter is not a middle option: the hash covers the full URL, so a stripped
value is no preimage plus text that looks anchored.

REVOKE UPDATE IS A STORAGE CHOICE, AND REVOKE MAY DO NOTHING

The transaction log's mutability depends on where report state lives. If the
consumed quantity lands on the transaction row, the log is mutable; if report
state goes to separate tables, the log stays insert-only and UPDATE can be
revoked. The protocol does not decide it, and the previous revision stated one
answer as though it did.

More useful, and previously missing: table grants do nothing when the
application connects as the database owner, which many deployments do. REVOKE
UPDATE, DELETE then reads like enforcement and enforces nothing. Where
append-once has to hold regardless of role, the mechanism is a BEFORE UPDATE
OR DELETE OR TRUNCATE trigger.

THE CORRELATION ID MAY REACH THE DELIVERY SIDE — NOTHING GUARANTEES IT

The previous revision said the delivery fetch does not carry the execute
request's correlation header. Too absolute: a client may propagate it across
all three legs, and a reference SDK does exactly that on purpose. What holds
is narrower — no contract requires it, and the delivery record has no field to
keep it in — so the join to build on is the hash, and a correlation-id match
on the delivery side is a convenience.

TWO PROPERTIES THAT READ LIKE DEFECTS AND ARE NOT

An old row's agent_public_key will not match the current registry after the
agent rotates. That mismatch is the reason the field exists: the row attests
which key signed these terms and where this Exchange obtained it, so a
registry-match expectation would be false on purpose. Pinning the key locally
after fetching it from the agent's directory is likewise fine — the directory
is still the anchor, consulted at registration and rotation.

The threat model now also states the boundary plainly: independent proof that
the agent published a key at that directory would need archived snapshots or
an append-only key history, and neither exists. An attacker controlling an
Exchange can write a row whose signatures verify under a key the agent never
published.

Separately, an Exchange that resolves the verifying key per item can persist
rows for one request whose agent_public_key values differ, if a rotation lands
between items. Every such row is individually valid and the response names
only the first item's key. Resolving once per request removes the window, but
rows already written keep the property forever, so the storage model documents
it as something a reader can find rather than as a bug that gets fixed away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
…sertion

The field was added unruled in this revision. A ledger renders it, so an
unbounded string re-opened on a new field exactly the surface the correlation
id's printable-ASCII bound closes: control characters, terminal escapes and
newlines reaching a rendered forensic row. The evidence plane had one hardened
correlation field and one unhardened broker field beside it.

It now carries max_len 255 and ^$|^[!-~]+$.

The rule bounds the SHAPE and deliberately does not pin the FORMAT. The value
is implementation-defined provenance for the outermost relay hop, so a
thumbprint pattern would invalidate a row for a transaction that legitimately
executed under a server that records it some other way — the requester_id
reasoning. Printable ASCII at 255 admits every provenance form a server might
reasonably record while refusing the shapes that only matter to a renderer.

The alternation admits the empty string on purpose. '' is one of the field's
three states — recorded, and the acceptance arrived direct — and a bare
^[!-~]+$ would need at least one character, deleting that state and leaving
absence to mean both "not recorded" and "arrived direct". Same alternation
shape agent_directory_url already uses, for the same reason.

The message-level SCOPE OF THE GUARANTEE list also names broker now. That list
enumerates the unsigned Exchange self-assertions in the row so a reader knows
which values carry no cross-party proof, and broker was missing from it: a
ledger row saying "routed by X" reads as a cross-party fact when it is one
party restating its own observation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
The changelog existed twice. proto/CHANGELOG.md sits beside the schema; the
published page carried a hand-copied second body. Nothing generated it, no test
compared them, and the doc-conformance script did not look at it — only the
author's memory connected the two, and every new entry had to be written twice.

They had already diverged. The two bodies differed by 436 lines, most of it
reworded prose saying the same thing twice, and one whole entry — the generated
SDK parity matrix — existed only in the file and had never reached the page an
integrator reads. That is the failure the duplication makes inevitable, already
realised.

The page is now proto/CHANGELOG.md with Starlight frontmatter in place of the H1
title, rendered by scripts/gen-changelog-page.py, gated in ci-local.sh by the
same regenerate-and-diff shape as gen/ and the validation corpus. The file beside
the schema is the source, because it is the one a schema change is written next
to and the one a reviewer reads in a proto diff. The generated page carries a
do-not-edit marker naming its source.

The generator refuses to write a page that would break the MDX build. MDX is
stricter than Markdown — a bare '<' or '{' outside a code span parses as JSX — so
the script checks for those and fails naming the line, rather than letting the
error surface later as a Vite stack trace against a generated file.

ONE SIDE EFFECT WORTH KNOWING

Generating the page brought the FULL changelog history under the site's
proto-symbol gate for the first time. That gate fails the build on a reference
whose proto type exists but whose member is gone, and the hand-copied page had
quietly dropped or reworded five such references from older revisions:
DENIAL_REASON_ENTITLEMENT_STALE_ATTENUATION, Image.caption, Requester.license_id,
Text.originality and TransactionRequest.offer_id.

All five are the intended case for the ignore list — a changelog entry describing
a removal has to name what was removed. They are listed with a note explaining
where they came from and why removing one means rewriting a shipped changelog
entry rather than fixing a typo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
…section

The prevention taxonomy is what an operator reads to decide which threats still
need a deployment control. Two admin-plane rows were filed in the bucket that
says no control is needed, and their own countermeasure text said otherwise.

T-ADM-1 (evidence-row enumeration) sat under "protocol changes make the attack
structurally impossible" while its countermeasure named the network boundary as
the outer control. A network boundary is a deployment control by definition. The
pair selector narrows what a stolen transaction id is worth; it does not stop a
caller who can reach the plane, and the entry's own residual already said so.

T-ADM-4 (fabricated evidence row) sat in the same bucket while its countermeasure
was documenting a trust boundary and comparing keys out of band. That is
detection, and it depends on a verifier actually performing the comparison, so it
moves to detectable-via-reconciliation.

The taxonomy gains a row it did not have: bounded only by a deployment control.
Without it there was nowhere honest to put T-ADM-1 — the three existing buckets
are structurally prevented, detectable, and legally enforceable only, and this
threat is none of those.

A PER-TENANT ACL IS NOT AVAILABLE, AND TWO PLACES IMPLIED IT WAS

T-ADM-1's countermeasure said naming the tenant in the request is what lets a
deployment put a per-tenant ACL in front of the RPC, and its residual said an ACL
narrows the caller. Neither is possible. The admin plane carries no request
signing and no per-operator identity, so there is no caller identity to attach an
ACL to. That is a property of the plane's design rather than an unimplemented
feature, and the text now says so, because "add an ACL" is the obvious wrong
conclusion for an operator to reach from the old wording.

THE GATE NOW GUARDS SOMETHING LARGER THAN IT USED TO

Stated plainly for the first time: the pre-existing admin RPCs are config writes
scoped to one named tenant. The evidence read is a cross-tenant read of the whole
plane — anyone inside the allowlist can read every tenant's signed offers,
pricing, licensing terms and quota. Same gate, different consequence, so an
allowlist sized for fee-rate writes under-protects the read.

SECTION NUMBERING

The admin-plane group was inserted as a second "## 7.", colliding with "## 7.
Attestation-Specific Threats". Attestation, delegation and licensing shift to 8,
9 and 10. No published anchor breaks: nothing links to a numbered section
heading, only to a threat subsection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
… store

RequestIDMiddleware read X-Request-ID and, if non-empty, propagated it verbatim.
It minted a value only when the header was absent, and checked nothing.

ramp.admin.v1.RequestCorrelation.request_id requires printable ASCII, 1 to 255
characters. That field sits inside a required message inside a required field of
GetTransactionEvidenceResponse, so a stored value outside the rule does not
degrade the response — it invalidates the whole of it. A caller who gets a
hostile header persisted therefore breaks the forensic row for their own
transaction permanently, with one HTTP header and no other access. The
middleware handed such values straight through.

WHAT IT DOES NOW

A received header is propagated only if it conforms. A nonconforming one is
REPLACED rather than passed through or dropped, which keeps every request
correlated — the reason the middleware exists — while the provenance flag
carries the distinction the value itself cannot.

The contract fixes the invariant and leaves the mechanism open, so recording no
correlation at all is equally conforming. A server that prefers that reads the
flag and declines to persist the pair.

The MINTED value is checked too. An application supplies its own mint through
WithRequestIDFunc, usually to reuse a trace id, and a trace id is exactly the
kind of value that carries a colon, a brace, or nothing at all. Without the
check the middleware would refuse a caller's newline and then insert its own.
The same guard goes on the client interceptor, which stamps a minted id on
outbound calls: sending one the receiving Exchange has to replace would silently
break the correlation the interceptor exists to create.

PROVENANCE IS NOW READABLE, WHICH IT WAS NOT

An evidence writer has to persist request_id_minted beside the id, and the
middleware previously returned nothing that distinguished a propagated value
from a minted one — so a server built on it could not fill that column
correctly even if it wanted to. RequestIDFromContext returns both halves.
Derived means server-derived: the header was absent, OR it was present and did
not conform. That matches what the column and the wire flag mean.

ValidRequestID is exported because the same test is needed twice: on the way in,
and again by anything reading a stored correlation back, since rows written
before a server had this check may hold a value it would refuse today.

It deliberately does not copy the reference Exchange's stricter charset. That
one is inside the contract and avoids every log-injection metacharacter, but the
SDK should not refuse values the contract can represent; narrowing is a
deployment's decision to make on top of this.

TESTS

Hostile headers (newline, carriage return, NUL, terminal escape, tab, space,
non-ASCII, DEL, oversize) are each asserted to be replaced, with the result
itself conforming and flagged as derived. The counterpart asserts conforming
ids — UUIDs braced and bare, W3C trace ids, punctuation-heavy tokens — survive
verbatim and are NOT flagged, so a middleware that replaced everything would
fail rather than pass. Boundary cases pin 0x20/0x21 and 0x7e/0x7f, which no
realistic sample string would catch.

Verified by mutation: forcing ValidRequestID to accept everything turns the
suite red rather than leaving it green.

The three new exports are Go-only, like the middleware they belong to, and are
recorded as such in the parity map with the note that they move if a Python or
TS server face ever lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
…imed

Four small gates, one shape: each reported "no problem found" when what it
actually meant was "I did not look there".

ENUMS WERE OUTSIDE THE BARE-NAME NAMESPACE CHECK

AssertUniqueBareNames walked messages only. The corpus, the merged JSON-Schema
$defs and the doc markers all key by bare name, and merge_schema.py hoists enums
into the SAME $defs map as messages, so an enum could collide with an enum or
with a message and nothing would say so.

The enum half is the one that fails quietly: enum $defs are keyed with
setdefault, so the second of a colliding pair is dropped and every field
referring to it silently takes the first one's value list. Generated Pydantic
and Zod would then accept values the Go server rejects.

The gap was invisible because ramp.v1 was the only package defining enums until
this revision added ObligationState. A guard cannot report what it was never
given, so a companion test now asserts the walk reaches enums, in more than one
package, including nested ones — a sweep that quietly stopped visiting them
would otherwise leave the uniqueness test green.

BYTESGEN FELL OPEN ON A FIELD WITH NO RULE

The manifest walk visits fields that HAVE rules, so a bytes field with none was
never visited and shipped with protoschema's loose base64 rendering — in a
generator whose header says it fails closed on everything it cannot translate.
That rendering accepts values Go protojson refuses and refuses url-safe values
Go accepts: wrong in both directions.

It now walks every bytes field and dies on an unruled one unless it is an
explicit exception. The list is self-cleaning both ways: a new unruled field
fails until someone decides about it, and an entry that stops naming a live
unruled field also fails, so an exception cannot outlive what it excuses.

One field is listed. It is a JWS compact serialization, so neither len nor
min_len describes it — what it needs is a base64url-faithful pattern, which the
tightening pass cannot emit today. Tracked separately.

The count in the report that prompted this was wrong: it named five unruled
fields. The contract has six bytes fields and exactly one is unruled.

THE CR/LF DIVERGENCE DOES NOT EXIST

The same report said Go protojson skips CR and LF inside a base64 value while
the generated patterns admit neither, making a line-wrapped value server-accepted
and client-rejected, and asked for the claims to be softened and the deviation
recorded.

Measured instead. protojson v1.36.11 REFUSES a newline mid-value, a trailing
newline, a CRLF and an embedded space. Pydantic refuses all of them too. There is
no divergence, and softening the claims would have introduced a false one.

What is real is a latent trap one dependency setting away. Python's own `re`
treats `$` as matching before a trailing newline, so a Pydantic build switched to
the python-re engine would start accepting a trailing-newline value while Go and
Zod still reject it — and nothing in the suite would notice. Pydantic's default
Rust engine does not, which is the only reason the languages agree today.

So the worry becomes vectors rather than prose: six rows across both form sets
pin newline-inside, newline-trailing and embedded-space as rejected, asserted
against the Go oracle and both generated clients. A behaviour change now turns a
gate red instead of being argued about in a comment.

NOTHING TIED THE PINNED ALGORITHM LABEL TO THE CONSTANT THAT WRITES IT

The evidence row pins "EdDSA" as a string.const on both algorithm fields; the
SDK writes the value from its own constants. Change a constant and every Go
test, every parity test and the corpus stay green while every row the Exchange
writes is refused by its own read RPC — surfacing in production, on the forensic
plane, when someone needs it.

A guard now reads the const from the DESCRIPTOR and the constant from the
committed wire vector, so neither literal is restated: a third copy would be a
third thing to drift. The two constants are exported into that vector, which the
Python and TS parity suites already replay, so a change missing either side goes
red. Scope is derived rather than listed — any pinned algorithm-label field that
is not bound to a constant fails, so adding a third fails until someone says
which constant produces it.

MERGE_SCHEMA ARGUMENTS

The three manifest parameters were optional, so calling the script with too few
arguments skipped the matching tightening pass and exited 0 — a smaller, laxer
schema and a green build. They are required positionals now, and the comment
recording the old slice bound had the wrong number.

Every guard here was checked by mutation: breaking the thing it watches turns it
red, and for the allow-list, both directions do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
…y example

Two halves of one defect: a signature field that never said how it is written,
and 71 published examples that answered the question wrongly.

THE ATTESTATION SIGNATURE HAD NO STATED ENCODING

ResourceAttestation.signature carried no rule, and its comment described the
signed BYTES precisely — an Ed25519 signature over the RFC 8785 JCS form of
{verifier, keyid, attested_at, uri, claims} — while never saying how the
signature itself is written. A vendor had to guess.

It is hex now: 128 characters, either case, with the same pattern rule as
Offer.signature and AgentAcceptance.signature. Every detached signature in this
contract is written the same way.

Settled rather than merely documented, because an attestation is the worst place
in the protocol to leave an encoding open. The signing party is a third party
who never negotiated with the reader, so two vendors guessing differently
produce attestations neither side can verify, and nothing on the wire explains
why. The other two signature fields had an SDK writing them, which decided the
question in practice long before it was written down; this one has no producer
at all, so nothing was deciding it.

Breaking in the descriptor and refuses nothing conformant: no SDK produces or
verifies an attestation signature today. The rule also makes the field mandatory
in practice, since the empty string does not match — which restates what the
message already means. The field comment defines an attestation with no
signature as Level 0, "no attestation present", not as an attestation missing a
field.

EVERY PUBLISHED SIGNATURE EXAMPLE IS NOW HEX

71 values across 14 pages showed a signature as a base64 placeholder
(base64-ed25519-...), a truncated token (a1b2c3...), or a bare ellipsis. The
offer and acceptance ones had been wrong since the JWS-to-hex settlement; the
attestation ones were unpinned until the rule above.

The sweep is deterministic rather than hand-assigned: each distinct placeholder
maps to its own value, so a signature appearing several times in one walkthrough
keeps the same value throughout, and two different signatures in one example
stay visibly different. Values already in the correct form were left alone, and
signature_algorithm was not touched — the replacement matches a `signature` key
only, never a prefix of a longer one.

Corpus 668 to 684 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
…-out

Two claims about where a value comes from, both written from a schema rather
than read off a store, both now corrected against the reference.

ReportingObligationState said consumed_quantity was the one field with no
column behind it — sourced from the accepted usage report rather than the
obligation row. There is a column, written in the same statement as the state
transition when a report validates. So every field here is column-backed, with
no exceptions and no translation step.

The correction matters more than a mislabelled source. A single field sourced
elsewhere means a reader cannot tell, from the message alone, which values a
server had to go looking for — and completeness is the property that makes this
a projection rather than an assembly. The comment now states it as the rule it
is.

The storage model claimed the reference writes report state to a
reporting-obligations table AND a per-report table. The per-report table has
never been written by any code: no query, no repository, no service reference.
It is schema residue from an early migration, and the description of it as "one
row per accepted report" described the schema rather than the code — the same
defect shape this correction is fixing one line up. The sentence now names what
is actually written: the state transition, the consumed quantity and the
validation outcome, all onto the obligation row.

Nothing changes for a reader of the wire: the field comment on
consumed_quantity was already accurate, and no rule moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
The page presented a shared-secret HMAC scheme as the current implementation.
No Exchange has ever signed that way. The Exchange-connected side swept its
whole repository: no signer, no verifier, no config path, nothing in the edge
worker, nothing in deploy manifests. The one remaining HMAC artifact is a
tenant column its own repo documents as never used for URL signing.

So the page was wrong on every axis at once — algorithm, key model, encoding
and canonicalization — and it described something that was never the deployed
contract rather than something that had drifted.

WHAT REPLACES IT

Two schemes, selected per tenant when the URL is minted: Ed25519 verified by
edge function code, and RSA verified natively by CloudFront. Both asymmetric.
No shared secret exists in either, and no verifier holds signing material —
stated explicitly, because the old page's key-custody section recommended
moving to asymmetric signing as though that were a future step.

The Ed25519 canonical form is published in full, because a signer and a
verifier that disagree by one byte reject every URL. Three points are called
out that a reader gets wrong otherwise: the prefix is verbatim with no host,
port or path normalization; sig is dropped BEFORE sorting rather than after;
and the query is decoded and re-encoded rather than passed through.

Those are not taken on trust. The repo's own signedurl-vectors.json fixes
complete signed URLs byte for byte from a fixed key seed, and four of its cases
each pin one clause of the verbatim rule — mixed-case host, explicit :443, a
space in the path, and %2F left encoded. The three SDKs replay that file, so
the published form is the form all of them produce.

CloudFront's policy and parameter format is AWS's contract and is linked rather
than restated; a second copy would drift. Two RAMP-specific facts stay: agent_id
is set before signing so the canned policy commits to it, and the reconciliation
digest is the same on both schemes, which is what keeps the delivery join
scheme-independent.

THE SCHEME IS NOT A WIRE VALUE

Stated plainly because the old page implied a verifier chose a mode. The scheme
selects the SIGNER; verification never consults it, since each scheme's URL is
self-describing. That removes the absent-or-unknown-scheme question from the
verifier entirely: an unrecognized scheme fails at mint, before any URL exists.

PROOF OF POSSESSION IS THE DIFFERENCE THAT MATTERS

Ed25519 URLs are verified by code that can require the fetcher to prove it holds
the bound key. CloudFront verifies natively and cannot. The page now leads with
that distinction and returns to it, because it decides whether a leaked URL is
usable AND whether a stored URL is a live capability at rest — which is the
basis of the full-URL storage rule in the Exchange storage model.

Key resolution and the 403-vs-503 split are marked as reference behaviour, not
protocol: a second implementation must fail closed, not fail closed in those two
flavours.

Agent identity binding, TTL checking, single-use enforcement and the KV
consistency table are unchanged in substance — they never depended on the
signing scheme. The binding section's HMAC-locked wording becomes
signature-locked, and the timing-safe section loses a table row measuring an
operation that does not happen.

Four sibling pages still describe the HMAC scheme. They are filed rather than
swept here: they name a config surface (a signingMode field, an HMAC_SECRET) that
exists in no code in this repository, so correcting them means guessing at an
interface rather than reading one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X65wHpv4e1JhHzUEU5nCHM
The page used txn_id in two places: as the KV key for single-use enforcement,
and in the key-custody diagram, which said the Exchange signs "canonical URL
incl. exp, agent_id, txn_id".

The signer does not add it. sdk/go/helpers/signedurl.go sets exp, kid, agent_id
and sig, and nothing else; the shared vector file shows the same four. The
Exchange-connected side lists the same set independently.

A transaction id can still be on the URL, but only because the Exchange put it
on the resource URL before signing. It is covered by the signature either way,
since the canonical form takes the query verbatim.

So the diagram now states what the signer actually adds, and the single-use
section says where a per-URL key comes from: a transaction id when the Exchange
supplied one, otherwise sig, which is unique per URL by construction.
Adding the two signature-algorithm constants to wire-constants-vectors.json
turned both parity suites red: KeyError in Python, "expected undefined to be
defined" in TypeScript. Neither failure named the real problem.

The cause was a private Go-name -> local-name map in each suite. That map made
the check opt-in. A vector with no entry was not reported as an unported
constant; it crashed the lookup. So adding a vector broke the test instead of
demanding a port, and the message pointed at the map rather than at the gap.

The mapping was already maintained elsewhere. sdk/parity/symbol-map.json
carries it for the API-surface gate, and it had both constants recorded
correctly all along - OFFER_SIGNATURE_ALGORITHM and
ACCEPTANCE_SIGNATURE_ALGORITHM, which live in ramp_sdk.core and in
offer-sign.ts / acceptance.ts rather than in the wire module. Two copies of one
fact, and the private ones were stale.

Both suites now resolve the local name through symbol-map.json and fail with a
sentence when a vector has no mapping. Map keys are package-qualified while a
vector names the bare identifier, so agreeing duplicates collapse - the Go layer
defines RequestIDHeader in both helpers and core - and a disagreement fails.

The Python suite also pins the wire module by derivation rather than by list:
every public name it defines that has a vector must carry the vector's value.

Mutation-tested. A vector with no symbol-map entry turns both suites red and
names the missing constant.
…enerated ones

ci-local ran gen/python/tests and gen/ts. sdk-types-ci.yml runs those AND the
hand-written sdk/python/tests and sdk/ts suites. So two whole suites - 664 and
643 tests - existed only in CI, and a change that broke them still produced a
green local run.

That is not hypothetical. Two wire-constant vectors added on this branch turned
both suites red while ci-local reported PASS. The break was found by running one
of the files by hand on a hunch.

The most expensive gap the missing wiring left open is the API-surface gate in
sdk/python/tests/test_api_surface_parity.py. It fails when a new exported Go
symbol is neither mapped nor excluded in sdk/parity/symbol-map.json - which
happens on any commit that adds a Go export. The gate is correct and reports at
the wrong time: the contributor sees green, pushes, and learns from CI after the
review has started.

The Python step installs the same version floors sdk-types-ci.yml installs,
rather than the hash-pinned set used for the generated types, so it gates on
what CI actually checks. sdk/ts uses npm install because it ships no lockfile,
which is also what CI does.

Both steps sit inside the sdk-types block, so RAMP_CI_SKIP_SDK_TYPES=1 still
skips them under proto-ci.yml, where sdk-types-ci.yml owns that gate.

Mutation-tested: adding an unmapped exported Go symbol to sdk/go/core turns the
run red and names the symbol.
Shipped comments cited decision-record numbers 144 times across the Go, Python
and TypeScript SDKs, the proto, the README and the changelog. No such document
exists here. There is no adr/ directory and no decision-record page on the site;
those documents are in the implementation repository, which a reader of the
published protocol repo cannot open. Six sites also cited an internal API
document by filename, which is likewise not in this tree.

The defect is the same one the no-ephemeral-ids rule already covers, one step
wider. A pointer the reader cannot follow carries no information for them. It
also reads as authority: the sentence sounds sourced, so a reader is less likely
to question it and cannot check it either.

Almost all of them were decoration. The sentence already stated the rule and the
number sat in a trailing parenthesis, so the substance survives the cut intact:
"pinned to the shared vectors in testdata/thumbprint-vectors.json" says
everything the citation was standing next to. Where the number was used as a
name - "the ErrorDetail envelope", "the failure envelope", "the L1 byte
contract" - the phrase is now written out, which is shorter and clearer than the
version that made the reader resolve an identifier first.

Nothing was deleted that left a rule asserted with no reason, and no decision
was published into this repo to make a citation resolve. References to public
standards and to paths inside this tree are untouched and remain the right way
to source a claim.

gen/ and the changelog page follow their sources.

The convention is extended in CLAUDE.md and AGENTS.md, which are untracked local
files: a shipped comment must not point at a document that is not in this repo.
… and Zod

protoc-gen-jsonschema SPLITS a leading proto comment. When the comment has a
blank-line-separated first paragraph, that paragraph becomes the JSON Schema
`title` and only the remainder becomes `description`. merge_schema.py then
dropped every title, so the opening paragraph reached Go and vanished from
gen/python/wire/models.py and gen/ts/wire/schemas.ts.

The loss was silent. Nothing failed, nothing warned, and a reviewer reading the
proto or the Go bindings saw a complete comment. It was found by accident, while
rewording a field and noticing the regenerated docstring started at the second
sentence.

28 field comments were affected, several of them normative text: the dedupe-scope
invariant on the three idempotency_key fields, the detached-Ed25519-not-JWS
statement on Offer.signature, the entropy split on
TransactionResultItem.transaction_id. A Python or TypeScript reader got each
comment without its opening claim.

Keeping every title is the other wrong answer, and is why the strip existed:
datamodel-code-generator names classes from titles, so a sentence-long title
would become a class name.

So the two kinds are separated by HOW the title was produced, read off the node
shape rather than guessed from the text. A message title is generator-derived
when its words rejoin to the message name (Acceptable Restriction ->
AcceptableRestriction). A property title is generator-derived when the property
is enum-typed, which the emitted node shows as an anyOf arm carrying the
SCREAMING_SNAKE value list. Everything else is comment text, folded in front of
the description with the paragraph break the author wrote.

33 enum-type labels are still dropped. 28 comment paragraphs now survive.

Gated: no title may reach the merged schema. A shape the fold does not classify
stops the pipeline instead of being silently discarded.

Mutation-tested twice. Keeping titles instead of folding them makes the gate fire
and gen-sdk-types.sh exit 1. Adding a blank-line-separated lead paragraph to a
field puts that text in both generated clients, which is what used to be lost.

The single-paragraph workaround is no longer needed. Existing comments written
that way are left as they are; they read correctly either way.
…ents, and six other drifts

Seven defects, each one a change on this branch that landed with a frame smaller
than its reach. Both sides of every contradiction below are new here, so none of
it is inherited drift.

A PROTO FIELD NAMED `title` WAS RENAMED TO `description` IN PYTHON AND ZOD

fold_titles walked every dict the same way and deleted the `title` key from all
of them. In a JSON Schema node `title` is a keyword. In a `properties` map it is
a FIELD NAME, and ramp.v1 declares three: Offer.title, ResourceEntry.title,
UsageAsset.title. Each was deleted and its schema node written back under the
key `description`, so OfferSchema.parse() dropped a real field and advertised
one no server accepts. Fixed by naming the maps whose keys are author-chosen
(`properties`, `patternProperties`, `$defs`, `definitions`, `dependentSchemas`)
and walking their VALUES as nodes, never the map itself.

Nothing caught it because assert_no_titles could not fire: fold_titles removed
every `title` key before the guard scanned the serialized text. The guard now
walks nodes and maps the way the fold does, so it can fail on an unclassified
node title and cannot report the three legitimate fields.

AN ENUM FIELD'S FIRST PARAGRAPH IS DROPPED BEFORE THIS PIPELINE RUNS

Separate defect, same file. protoc-gen-jsonschema puts the enum TYPE NAME in the
title slot and discards the paragraph that would have gone there. The text is
absent from the plugin output, so no classification change can recover it. Three
fields were losing documentation this way, one of them the sentence saying when
DiscoveryResponse.absence_reason is set and when it is unset. They are single
paragraphs now, and a conformance test fails on any enum-typed field whose
leading comment splits, so the rule is held rather than remembered. Merging two
paragraphs of three does not help — whatever ends up first is what disappears —
and the test says so, because the first attempt at this fix made that mistake.

THE REQUEST-ID GUARD WAS APPLIED AT TWO OF THREE SITES AND DOCUMENTED AS ABSENT

The middleware began validating and replacing a nonconforming X-Request-ID.
Three consequences went unfinished. The contract comment and the threat-model
residual still said the SDK "performs no conformance check of its own" — true
when written, false three commits later. The contract no longer describes any
SDK's behaviour: it cannot gate that statement, which is why the statement went
stale. ValidRequestID restated the descriptor rule as hand-typed Go with nothing
binding the two; a differential test now asks protovalidate and asks the Go
function and requires them to agree, restating no literal from the rule. And the
mint-validate-fallback ladder was pasted into one call site while a third
stamping site had no check at all, so one call could send two different ids.
core.MintRequestID is now the single validated mint that every site takes.

NINETEEN ENUM FIELDS HAD HALF A CORPUS CASE

The enum edge took the honest-label rename and never got the companion case that
the string, bytes and list edges received in the commit after it — the enum site
is where the idea started, which is why extending the pattern skipped it. The
explicit `*_UNSPECIFIED` wire shape is a different parse path in a generated
client, and it had no case. Corpus 684 -> 703.

THE ATTESTATION RECIPE STILL SAID BASE64

ResourceAttestation.signature is pinned to 128 hex characters. The sweep that
converted 71 example values missed the normative construction step on the same
page, because that line is an assignment in a pseudo-code block rather than a
JSON value. It was the last base64_encode in the repository.

SIGNED URLS, MIXED ACCEPTANCE KEYS, AND THE USAGE-REPORT DEDUPE NAMESPACE

Two lines added here still called the retrieval URL HMAC-signed after the
verification page was rewritten around two asymmetric schemes; both were edited
for the acceptance-key change and the phrase was carried through unexamined. The
remaining sites across nineteen pages predate this branch and are tracked
separately.

The storage model described a mixed-key request as producing "individually
valid" rows while the proto made one key per request a MUST. The Exchange side
settled it: an agent that signs every item with one key cannot produce the pair,
so a mixed-key row is the trace of a requester already in violation slipping
through an enforcement race, not traffic the rule newly outlaws. The section now
leads with the MUST and says that plainly. The race stays open until the
Exchange closes it, so the disclosure is worded as such rather than as history.

The usage-report dedupe block reasoned only about two agents behind one Broker.
"Filed by the agent or Broker" means the Broker forwards the agent's report
unchanged, so the two paths carry one key and must collapse; a sentence now says
so, and says that adding the verified signer would split them and double-count.

Corpus 703 cases, all three language runners green. go vet clean. Every
generator re-run produces byte-identical output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LUNqNnXYEze1mybYfcpNc
…tle three contradictions

The offline re-verification recipe on TransactionEvidence stated two independent
signature checks and introduced the second with "the agent accepted this exact
offer". Nothing in the procedure established "this exact". A genuine offer from
one transaction and a genuine acceptance from another, by the same agent, both
verify against real keys and both pass the authenticity step the same comment
describes -- and the spliced row asserts an agreement that never happened. The
recipe now reads offer_sig back out of agent_acceptance_canonical_bytes and
requires it to equal the row's offer_sig. The acceptance payload has always
carried offer_sig as field 1 for exactly this purpose; the recipe never read it.
The Exchange MUST perform the same comparison before persisting a row. The
conformance test executes all three steps and carries a splice case: two genuine
halves joined, both signatures verifying, caught only by the binding check.

Who may file a usage report is now stated. The dedupe namespace for UsageReport
and DisputeRequest is (transaction_id, key), which deliberately leaves the
verified signer out so a Broker relaying an agent's report unchanged collapses
into one report rather than two. Dropping the signer also removed a protection
that was never restated: when the namespace included the authenticated caller,
an unauthorized filer could only pollute their own slot. The slot is now shared,
so the rule is explicit -- only the bound agent may file, or a Broker relaying
that agent's report unchanged, and any other filing MUST be rejected rather than
deduped. An unauthorized filing reports as TRANSACTION_NOT_FOUND on purpose: a
distinct "not authorized" value would confirm to an unbound party that the
transaction exists. No enum value was added.

The proto no longer describes signed URLs two ways. The retrieval-URL block said
signed URLs use HMAC-SHA256 with a shared secret and offered a fallback to
"HMAC + short TTL + TLS", while the same block's identity-binding paragraph had
been rewritten to say "confirm the URL signature". Both schemes are asymmetric --
Ed25519 over a canonical message, and a CloudFront RSA canned policy -- and the
fallback now names the scheme that exists: a CDN that verifies the URL itself
before any function code runs, and therefore cannot check proof of possession.

The C2PA page no longer calls the attestation signature a JWS. Pinning
ResourceAttestation.signature to hex left three lines naming the old format in
the RAMP column of a C2PA-versus-RAMP comparison, and the reader of that page is
the third-party verification vendor the hex settlement exists to protect. Four
SDK comments calling EdDSA "the JWS alg" are corrected the same way: the name is
the JOSE algorithm identifier, the signature is detached hex.

Two drift gates now cover the detached-signature rule. ResourceAttestation.
signature carried the hex rule and a comment saying "the same rule", which is
not the phrase the restated-rule gate reads, so it was the one copy of five tied
to nothing; it now declares "Same rule as ramp.v1.Offer.signature". That gate can
only prove the five copies stay EQUAL, though -- move them all in step and a
changed shape passes. Measured: widening the class to ^[0-9A-Za-z]{128}$ passed
every gate and regenerated the corpus byte-identical. TestHexSignaturePatternAdmits
now pins what the rule admits: either case accepted, 127 and 129 characters and
non-hex characters and the empty string refused. The deliberate mixed-case
literals in the evidence fixture are marked load-bearing so a later tidy-up does
not silently remove the protection they carry.

Corpus stays at 703 cases. Generators verified at a fixed point: regenerated
twice, checksums identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LUNqNnXYEze1mybYfcpNc
…ing an ACL that cannot exist

The offline re-verification recipe compared one member of the signed acceptance
payload. Binding on offer_sig stops SPLICING -- a genuine acceptance from a
different offer no longer passes. It does not stop REUSE. Two acceptances by one
agent against ONE offer share offer_sig and differ only in the idempotency key,
so an Exchange holding a single genuine acceptance could write two evidence rows
for two different executes against one offer, and both passed all three steps.
That is fabrication by whoever writes the row rather than splicing by an
outsider, and it is the exact failure AgentAcceptancePayload.idempotency_key
exists to prevent.

The recipe now compares all four members against their stored copies. Three
names coincide; the fourth does not -- the payload member is idempotency_key and
the row stores it as request_idempotency_key. The row comment that enumerated
the payload's four fields used the row's name for the fourth, which would send
an implementer looking for a JSON member that does not exist; it now states the
mapping. The conformance test carries both attacks, and they are independent: a
spliced acceptance caught on offer_sig, a reused acceptance caught on the
idempotency key. Narrowing the check back to offer_sig alone leaves the splice
case green and turns the reuse case red, which is why both are there.

Four sites claimed the (tenant_id, transaction_id) pair selector is what lets a
deployment put a per-tenant ACL in front of GetTransactionEvidence: the service
comment, the RPC comment, the tenant_id field comment, and the hand-maintained
admin proto reference page. The threat model says the opposite twice and is
right -- this plane carries no request signing and no per-operator identity, so
there is no caller to attach an ACL to. All four now say what the selector
actually buys, which is that a leaked transaction id is not by itself a bearer
capability, and that the network allowlist is the only gate. The threat model
also records that this RPC widened that allowlist's blast radius from per-tenant
config writes to a cross-tenant read of every tenant's signed offers, so an
operator sizing it while believing a second control sat behind it would size it
too loosely.

The file header's "No shared secret in either scheme" was true of the two
signed-URL schemes and contradicted by cdn_type on
DomainVerificationConfirmation, where "hmac" is still an accepted value carrying
no validation rule. The claim is now scoped to delivery URLs and points at the
registration plane that still admits an HMAC key format, so the contradiction is
findable rather than hidden. Whether that plane should keep admitting it is a
product decision, tracked with the wider HMAC sweep.

Four parity exclusions stated reasons that were checkably false. Three said
"TS/Py have no server face"; both ramp_sdk.server_verify and
core/verify-request.ts exist and open by calling themselves exactly that, and
the retirement trigger those three carried had already fired and so could never
fire again. The real reason is narrower: neither server face carries a
request-id seam. The fourth said py/ts "mint request-ids inline"; neither SDK
mints anything -- both export the RequestIDHeader constant and no non-test code
sets that header, so every RPC from a py/ts client arrives with no correlation
id and the Exchange mints one. Two older entries repeating that claim are fixed
with it. The behavioural gap is now tracked as its own work instead of being
documented as an intentional difference in API shape.

Corpus stays at 703 cases. Generators verified at a fixed point: regenerated
twice, checksums identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LUNqNnXYEze1mybYfcpNc
…site

The changelog page is generated by copying proto/CHANGELOG.md. A link in that
file is written for someone browsing the repository, so it is relative to
proto/ -- `../docs/design-history.md` reaches the file from there. Copied
verbatim onto a page served from /reference/changelog/ it reaches nothing, and
starlight-links-validator failed the docs build. The hand-maintained page this
generator replaced carried the absolute URL, so the address was correct until
the copy became automatic.

Repo-relative targets are now rewritten to absolute URLs against the published
repository. The rewrite is fail-closed twice: the target must exist on disk, and
any relative target still present afterwards stops the run. Both diagnostics
name the line in proto/CHANGELOG.md, which is the file an author can act on --
so the line offset introduced by stripping the H1 is carried through the
existing MDX check as well, which had been reporting positions two lines low.

ci-local could not have caught this. It ran `npm test` in website/, while CI
runs `npm run build`, and the link validator only runs inside the build. The
docs build is now its own step, mirroring docs-ci.yml, so the gate that fails in
CI is the gate that runs locally. About ten seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LUNqNnXYEze1mybYfcpNc
@EugeneDymo EugeneDymo closed this Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants