Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions conformance/src/trace_tests/inclusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,36 @@ class InclusionError(ValueError):
def _outside_profile(value: Any) -> str | None:
"""Name the first value registry-anchor-v1 section 1 excludes, or None.

Non-integer numbers (NaN and the infinities among them) and integers outside
-(2**53 - 1) to 2**53 - 1 are outside the anchor-leaf profile: two
implementations of the four serialization rules write different bytes for
them, so a proof over them only proves what Python's ``json.dumps`` wrote.
Iterative, because the claim is attacker-controlled and may be deep.
Non-integer numbers (NaN and the infinities among them), integers outside
-(2**53 - 1) to 2**53 - 1, and unpaired UTF-16 surrogates are outside the
anchor-leaf profile: two implementations of the five serialization rules
write different bytes or cannot represent them, so a proof over them only
proves what Python's ``json.dumps`` wrote. Iterative, because the claim is
attacker-controlled and may be deep.
"""
stack = [value]
while stack:
v = stack.pop()
if isinstance(v, bool) or v is None or isinstance(v, str):
if isinstance(v, bool) or v is None:
continue
if isinstance(v, str):
try:
v.encode("utf-8")
except UnicodeEncodeError:
return "unpaired UTF-16 surrogate"
continue
if isinstance(v, int):
if not -_SAFE_INT <= v <= _SAFE_INT:
return f"integer {v} is outside the safe-integer range"
elif isinstance(v, float):
return f"non-integer number {v!r}"
elif isinstance(v, dict):
for k in v:
if isinstance(k, str):
try:
k.encode("utf-8")
except UnicodeEncodeError:
return "unpaired UTF-16 surrogate in object key"
stack.extend(v.values())
elif isinstance(v, (list, tuple)):
stack.extend(v)
Expand Down
4 changes: 2 additions & 2 deletions docs/crosswalks/acta-decision-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Acta is an IETF draft format for signed receipts that record whether an AI agent

[Spec section 3.3.3](../../spec/trace-v0.2.md#333-action-receipts-for-embodied-workflows-informative) describes action receipts as evidence that sits below a Trust Record: a per-action signed statement, bound to a call or session, that a verifier checks independently of the core record. The [embodied-workflow fixtures](https://github.com/agentrust-io/trace-spec/blob/main/examples/action-receipts/README.md) show one profile of that pattern, for a physical controller (a robot arm, a safety monitor) signing an assertion about a physical action.

This cross-walk describes a second profile of the same pattern: an **Acta decision receipt**, evidencing a software decision (an AI agent's tool call, decided by a local policy gate before it runs) rather than a physical one. The verification pattern in 3.3.2 is deliberately domain-agnostic; the intent here is to show it composes with an existing, independently specified receipt format without a wire-format change to either side.
This cross-walk describes a second profile of the same pattern: an **Acta decision receipt**, evidencing a software decision (an AI agent's tool call, decided by a local policy gate before it runs) rather than a physical one. The verification pattern in 3.3.3 is deliberately domain-agnostic; the intent here is to show it composes with an existing, independently specified receipt format without a wire-format change to either side.

This follows the acceptance bar raised on [trace-spec#97](https://github.com/agentrust-io/trace-spec/issues/97): carry Acta as external evidence by digest/chain head and issuer/key metadata rather than embedding a second receipt schema into the Trust Record; state exactly which Acta fields satisfy the action-receipt verifier obligations; and keep the physical/software boundary explicit.

Expand Down Expand Up @@ -57,7 +57,7 @@ Field names on the left are exact TRACE terms as used in [spec section 3.3.3](..
| `receipt.issuer_key_id` | `signature.kid` | Resolved the same way 3.3.3 describes: through a pinned, manifest-bound, or otherwise trusted key set, not carried in the receipt as a public key. |
| `receipt.linked_call_id` / `session_id` | `payload.session_id` | Acta s3.1 binds at session level; `session_id` is OPTIONAL, and when present MUST NOT contain PII or be correlatable across sessions unless the operator explicitly configures session binding. Acta has no per-call identifier field; a TRACE-composing deployment gets call-level binding by carrying the tool input's digest in `payload_digest` (below) or by a deployment extension field, and should say which. |
| `receipt.evidence_type` | `payload.type` | Namespaced receipt type (Acta s2.2), here `protectmcp:decision`. |
| `receipt.evidence_hash` | `payload.payload_digest` | OPTIONAL common field (Acta s2.2): `{hash, size, preview?}` over associated data (tool input/output) too large to embed. Serves exactly the external-evidence-by-digest role 3.3.2 describes. |
| `receipt.evidence_hash` | `payload.payload_digest` | OPTIONAL common field (Acta s2.2): `{hash, size, preview?}` over associated data (tool input/output) too large to embed. Serves exactly the external-evidence-by-digest role 3.3.3 describes. |
| `receipt.previous_receipt_hash` | `payload.previousReceiptHash` | Bare lowercase hex over the predecessor's full envelope including its signature (Acta s5.7). Note the deliberate difference from formats that hash payload only: re-signing an identical payload yields a distinct chain link. |
| `receipt.decision` | `payload.decision` (+ `payload.reason`) | `reason` (OPTIONAL) names the machine-readable cause, e.g. `policy_block`, `tier_insufficient`, beyond the bare outcome. |
| `receipt.signature` | `signature.sig` (with `signature.alg`) | `EdDSA` is mandatory-to-implement; `ES256` optional, `ML-DSA-65` recommended for new deployments (Acta s5.8). |
Expand Down
46 changes: 26 additions & 20 deletions examples/verifier-compatibility/README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# Verifier-compatibility vectors (proposed, not accepted)
# Verifier-compatibility vectors

> These fixtures encode the obligations proposed in
> [agentrust-io/trace-spec#116](https://github.com/agentrust-io/trace-spec/issues/116).
> **No normative text for them has been accepted.** Do not read a passing vector here
> as a conformance requirement.
> These fixtures encode Obligations 2 and 3 of
> [agentrust-io/trace-spec#116](https://github.com/agentrust-io/trace-spec/issues/116),
> which are normative in `spec/trace-v0.2.md` §3.3 and documented in
> `docs/verification.md` ("Step 4: Check the EAT profile"). Obligations 1 (emitter
> version declarations) and 4 (downgrade disclosure) remain deferred.

Compliance evidence is verified years after it is issued, so the artifact routinely
outlives the verifier build that reads it. Nothing in `docs/verification.md` currently
says what a verifier must do about version skew, which leaves two failure modes open:
a revision silently orphans existing evidence, or a verifier "helpfully" accepts an
artifact under semantics it does not implement.

The proposal is four obligations: records carry an explicit profile, a verifier
declares the set it supports and refuses anything outside it, the verification
statement names the profile it ran under, and any fallback is disclosed rather than
silent.
outlives the verifier build that reads it. `spec/trace-v0.2.md` §3.3 and
`docs/verification.md` ("Step 4: Check the EAT profile") address version skew by
requiring a nonempty `accepted_profiles` set and recording both the verified `profile`
and the complete `accepted_profiles` set in the verification result. Without those
checks, two failure modes stay open: a revision silently orphans existing evidence, or
a verifier "helpfully" accepts an artifact under semantics it does not implement.

#116 proposed four obligations: records carry an explicit profile (Obligation 1,
deferred for standalone emitter version declarations beyond `eat_profile`), a verifier
declares the nonempty `accepted_profiles` set it supports and refuses anything outside
it (Obligation 2, normative in `spec/trace-v0.2.md` §3.3), the verification statement
records both the verified `profile` and the complete `accepted_profiles` set
(Obligation 3, normative in `spec/trace-v0.2.md` §3.3), and any fallback is disclosed
rather than silent (Obligation 4, deferred).

## The vectors

Expand Down Expand Up @@ -85,12 +91,12 @@ reports one generic refusal for all of them conforms, and two controls hold that
`test_a_generic_refusal_is_separated_by_nothing` in the separation module. Both fail the
day an assertion on the refusal's cause comes back.

This was settled on review, and the set was stricter than its text until it was. The draft
normative text for #116, held for a maintainer to carry and not part of this change, says
a verifier SHOULD report refusal-for-an-unimplemented-profile distinguishably from a
verification failure, which is coarser than a rule name and is a SHOULD, and it declines
to mandate any field name. Asserting the label was this set asking more of a foreign
implementation than the text it encodes.
This was settled on review, and the set was stricter than its text until it was. The
proposal for #116 said a verifier SHOULD report refusal-for-an-unimplemented-profile
distinguishably from a verification failure, which is coarser than a rule name, and
neither #116 nor `spec/trace-v0.2.md` §3.3 mandates any refusal field name. Asserting
the label was this set asking more of a foreign implementation than the specification
requires.

The label stays in the JSON because it tells a reader what each vector is for. This
library's own refusal messages are pinned separately, in
Expand Down
9 changes: 5 additions & 4 deletions spec/registry-anchor-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The two agree on records whose strings and keys are pure ASCII and whose numbers

- **Non-ASCII strings.** JCS emits the character; §1 emits a `\uXXXX` escape.
- **Non-integer numbers.** JCS applies ECMAScript number serialization. §1 excludes non-integer numbers from this profile for that reason.
- **Integers outside the safe-integer range.** JCS converts a number through an IEEE 754 double, so `9007199254740992` and `9007199254740993` become the same bytes; §1 serializes the digits as written, so they stay distinct. Two implementations of §1 do not even agree with each other here: Python's `json.dumps` writes the exact digits, and a JavaScript implementation of the same four rules goes through `JSON.stringify` and emits one value for both, which is a rug-pull this profile exists to catch going undetected. §1 excludes the range for that reason.
- **Integers outside the safe-integer range.** JCS converts a number through an IEEE 754 double, so `9007199254740992` and `9007199254740993` become the same bytes; §1 serializes the digits as written, so they stay distinct. Two implementations of §1 do not even agree with each other here: Python's `json.dumps` writes the exact digits, and a JavaScript implementation of the same five rules goes through `JSON.stringify` and emits one value for both, which is a rug-pull this profile exists to catch going undetected. §1 excludes the range for that reason.
- **Key order.** JCS sorts by UTF-16 code unit (RFC 8785 §3.2.3); §1 sorts by Unicode code point, which is what Python's `sort_keys=True` does. The two agree across the Basic Multilingual Plane and disagree once a key contains a supplementary-plane character, because surrogates occupy U+D800 to U+DFFF.

Do not "simplify" a verifier by reusing the signing canonicalizer at the leaf. If you do, write a test that anchors a record containing a non-ASCII character and verifies it, which is the test that catches this.
Expand All @@ -45,9 +45,10 @@ The unit of anchoring is the **complete signed claim object, signature included*

`canonical_claim_bytes` is the canonical JSON serialization of the claim object:

- Object keys sorted lexicographically by Unicode code point, recursively.
- Object keys sorted lexicographically by Unicode code point, recursively (UTF-16 surrogate pairs are not used for key comparison; see §0).
- Separators `","` and `":"`, no whitespace.
- Non-ASCII characters escaped (`ensure_ascii`); output encoded as ASCII bytes.
- String escaping: `"` (`U+0022`), `\` (`U+005C`), and the five control characters `U+0008`, `U+0009`, `U+000A`, `U+000C`, and `U+000D` use the two-character escapes `\"`, `\\`, `\b`, `\t`, `\n`, `\f`, and `\r`; `/` (`U+002F`) is not escaped; all other control characters (`U+0000` to `U+001F`) and all BMP characters in `U+007F` to `U+D7FF` and `U+E000` to `U+FFFF` (including `U+007F`, which is escaped as `\u007f`) use lowercase four-hex-digit `\uXXXX` escapes; supplementary-plane characters (`U+10000` to `U+10FFFF`) use lowercase UTF-16 surrogate pairs (a high surrogate in `U+D800` to `U+DBFF` followed by a low surrogate in `U+DC00` to `U+DFFF`, e.g. `\ud8xx\udcxx`); output is encoded as ASCII bytes.
- Integer zero is written `0` (never `-0`).
- No trailing newline.

In Python this is exactly:
Expand All @@ -56,7 +57,7 @@ In Python this is exactly:
json.dumps(claim, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")
```

Claims MUST be JSON objects. TRACE Trust Records contain only strings, integers, booleans, nulls, arrays, and objects. Claims containing non-integer numbers are outside this profile, because cross-language float serialization is not canonical, and claims containing an integer outside -9007199254740991 to 9007199254740991 are outside it for the same reason: an implementation of the four rules above in a language whose only number type is the IEEE 754 double writes one value for two distinct integers. TRACE v0.2 §3.2.2 already holds a Trust Record to that range, so a schema-valid record cannot reach this; a claim that is not a Trust Record can, and this is where it is excluded.
Claims MUST be JSON objects. TRACE Trust Records contain only strings, integers, booleans, nulls, arrays, and objects. Claims containing non-integer numbers are outside this profile, because cross-language float serialization is not canonical, and claims containing an integer outside -9007199254740991 to 9007199254740991 are outside it for the same reason: an implementation of the five rules above in a language whose only number type is the IEEE 754 double writes one value for two distinct integers. Claims containing an unpaired UTF-16 surrogate (`U+D800` to `U+DFFF`) in a string or object key are also outside this profile, because languages whose string type is UTF-8 cannot represent an unpaired surrogate. TRACE v0.2 §3.2.2 already holds a Trust Record to that range, so a schema-valid record cannot reach this; a claim that is not a Trust Record can, and this is where it is excluded.

## 2. Leaf hash

Expand Down
23 changes: 22 additions & 1 deletion src/agentrust_trace/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,17 @@ class UnanchorableValue(ValueError):
def _reject_unanchorable(value: Any, path: str = "$") -> None:
if isinstance(value, bool):
return
if isinstance(value, str):
try:
value.encode("utf-8")
except UnicodeEncodeError:
raise UnanchorableValue(
f"{path} contains an unpaired UTF-16 surrogate, which registry-anchor-v1 "
"section 1 puts outside the profile: languages whose string type is UTF-8 "
"cannot represent an unpaired surrogate, so the digest is not reproducible "
"by a peer."
) from None
return
if isinstance(value, float):
raise UnanchorableValue(
f"{path} is a non-integer number ({value!r}). registry-anchor-v1 section 1 "
Expand All @@ -438,8 +449,18 @@ def _reject_unanchorable(value: Any, path: str = "$") -> None:
)
if isinstance(value, dict):
for key, item in value.items():
if isinstance(key, str):
try:
key.encode("utf-8")
except UnicodeEncodeError:
raise UnanchorableValue(
f"{path}.{key!r} contains an unpaired UTF-16 surrogate in an "
"object key, which registry-anchor-v1 section 1 puts outside the "
"profile: languages whose string type is UTF-8 cannot represent "
"an unpaired surrogate."
) from None
_reject_unanchorable(item, f"{path}.{key}")
elif isinstance(value, list):
elif isinstance(value, (list, tuple)):
for index, item in enumerate(value):
_reject_unanchorable(item, f"{path}[{index}]")

Expand Down
Loading
Loading