Feature/transaction evidence - #33
Conversation
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>
Code review — diff-scoped, RAMP-239Multi-agent review of this PR's diff against RAMP-239's acceptance criteria 2-4 (the README section, What was run on this head: Should fix1. The offline verification recipe checks the row against keys the row itself carries. 2. The recipe has no executable test. 3. Three protocol rules are stated on the read plane instead of on the 4. The transaction-id entropy requirement is enforced in no layer. 5. The response cannot carry the signed-URL signature assertion the ticket asks for. 6. 7. The tightened pattern rejects the base64url alphabet that protojson accepts. 8. Nothing tests what the new tightening emits, and its padding length is derived twice. 9. The new drift gate for restated rules is not armed for copies this branch adds. 10. 11. Three new pieces disagree about whether 12. The new zero-edge guard does not close the class its own comment names. 13. Three new corpus cases are labelled as length boundaries while the JSON they emit omits the field. 14. The new wire surface is recorded in neither changelog. 15. The "always EdDSA" invariant is carried by comments, not by the schema. 16. The 17. 18. 19. The class 6 coverage guard is looser than it reads, and its own comment is stale. 20. The new gate compares rules but ignores presence, and reports a resolution failure as drift. 21. The evidence row's largest fields have a floor and no ceiling. Notes
|
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
No description provided.