Skip to content

spec(numbers): decide integer-valued members by value, with Python and JavaScript tests (#247) - #404

Merged
imran-siddique merged 5 commits into
agentrust-io:mainfrom
chernistry:feat/integer-by-value
Oct 5, 2026
Merged

imran-siddique merged 5 commits into
agentrust-io:mainfrom
chernistry:feat/integer-by-value

Conversation

@chernistry

Copy link
Copy Markdown
Contributor

What this changes

JSON has one number type. 1785000000, 1785000000.0 and 1.785e9 are three
spellings of one value, and RFC 8785 writes all three as 1785000000, so a Trust
Record carrying any of them has one pre-image and one signature. Section 3.2.2 does
not say whether a member typed integer holds an integer when it is written the
second or the third way. This proposal adds a paragraph that says it does: whether a
number is an integer, and whether it is inside the safe-integer range, is decided by
its value and not by its spelling.

This is a proposal, not a merge request for accepted text. Opened per the ruling on
#247 that a whole number within the existing limits counts as an integer, including
decimal and exponent spellings, brought as its own proposal with Python and
JavaScript tests and boundary cases.

What's open today

  • The schemas already decide by value. jsonschema follows JSON Schema 2020-12,
    whose integer matches any number with a zero fractional part (Validation §6.1.1),
    so a record with "iat": 1785000000.0 passes schema/trace-claim.json as it stands.
    No schema file changes in this PR.
  • The reference's verify_record does not. Its freshness check tested
    isinstance(iat, int), and json.loads returns a float for the fraction and
    exponent spellings, so a correctly signed record that had just passed the schema was
    rejected with record has no valid integer 'iat' for freshness check. The bridge's
    authorized_at, expires_at and transcript.after.observed_at checks carried the
    same type test.
  • A JavaScript verifier cannot tell the spellings apart at all. JSON.parse
    returns one number for all three.

So two implementations reading the same signed bytes can reach different verdicts
today, and the difference has nothing to do with the signature.

The rule

Added to section 3.2.2 after "What the rule covers", marked
<!-- CHANGED: #247 - integer-valued members are decided by value, not by spelling -->:

What counts as an integer. JSON has one kind of number. RFC 8259 §6 gives it a
single grammar in which a fraction and an exponent are optional parts, so
1785000000, 1785000000.0 and 1.785e9 are three ways of writing one value.
Whether a number is an integer, for a member typed integer and for the range above,
is decided by that value and not by how it is written: 1785000000.0 and 1.785e9
are the integer 1785000000, 1785000000.5 is not an integer, and 1e21 is an integer
outside the range. The value is the IEEE 754 double the number parses to, which is
what RFC 8785 serializes and so what a signature covers; a spelling with more digits
than a double carries, such as 1785000000.0000000001, is the integer 1785000000. A
verifier MUST make both decisions on that value. JSON Schema 2020-12, in which the
schemas of this specification are written, defines integer the same way
(Validation §6.1.1: any number with a zero fractional part). RFC 8785 writes the value
and not the text it was read from, so the three spellings above have one pre-image
and one signature, and the spelling is not something a signature can vouch for. A
verifier whose parser returns only the value, as JSON.parse does, has no spelling to
decide by; a verifier that decided by spelling would reject records that such a
verifier accepts, over the same signed bytes.

One MUST. The sentence pinning the value to the double is there for one edge: a
spelling with more precision than a double holds. An implementation that parsed to
arbitrary precision would call 1785000000.0000000001 a fraction, while every
implementation that parses to a double, which includes every JavaScript verifier,
calls it the integer 1785000000, and the signature covers that integer. The double is
the one reading every implementation can follow.

What this change does

  • Spec (spec/trace-v0.2.md, section 3.2.2): the paragraph above.
  • Reference (src/agentrust_trace/sign.py): _integer_value decides a member by
    value, refuses bool explicitly, and says which rule failed: ... is not an integer value: ... or ... outside the safe-integer range .... The iat freshness check
    uses it. _canonical_bytes now refuses a whole number past the safe-integer range
    written as a float (1e21, 9.007199254740993e15), with the IntegerDomainError
    that rfc8785 already raised for the same value written as an integer. The section
    3.2.2 range rule is on every canonicalized object, including the ones no schema
    constrains (bridge declarations, tool-call digests, transcript hashes), and read by
    value 1e21 is an integer outside the range; the rfc8785 package refuses an
    out-of-range int and writes the float as 1e+21 without complaint.
  • Bridge (src/agentrust_trace/intent_bridge.py): authorized_at, expires_at and
    transcript.after.observed_at are decided by value, with the reason in the error.
  • Adapters (src/agentrust_trace/adapters/agt.py, sandbox.py): the two transcript
    hashes go through _canonical_bytes instead of calling rfc8785.dumps directly, so
    every digest in the package draws the range in the same place.
  • Model (src/agentrust_trace/models.py): JsonInt reads a whole float as the
    integer it is and dumps the plain integer; it refuses a float that is not whole, and
    a numeric string, which pydantic's lax mode used to read as a number and the schema
    never accepted.
  • Docs (docs/schema.md): one sentence in the introduction.
  • Vectors (examples/number-spelling/): seven signed Trust Records, their
    generator, a README, a JavaScript test and a scan script; see below.
  • Tests (tests/test_integer_by_value.py, tests/test_adequacy_all_sets.py): the
    boundary table at every layer, the vectors recomputed from committed bytes, plausible
    shortcut implementations graded against the set, and the set registered with the
    adequacy checks.

schema/, the packaged schema copy and the v0.3 draft are unchanged, byte for byte.

Change classification

This PR does not set MAJOR/MINOR/PATCH for itself; that is your call. What it does to
verdicts:

  • Accepted now, rejected before: a record whose integer member is a whole number
    written with a fraction or an exponent (Python reference; the TypeScript verifier in
    feat(trust-record-verify): a TypeScript verifier for TRACE v0.2, scored against the Python one #376 already accepted it), and the same for the bridge's three time fields.
  • Rejected now, accepted before, in the Python library only: a numeric string for
    an integer member of the model, which the schema already refused; and a whole float
    past the safe-integer range in any object the library signs or digests, such as a
    nanosecond timestamp that went through a double, which the TypeScript canonicalizer
    in feat(trust-record-verify): a TypeScript verifier for TRACE v0.2, scored against the Python one #376 already refused. No record that passes the schema is affected by either:
    every object in the record schema is closed or held to canonicalizableValue, and
    every integer member is bounded.
  • No published verdict changes. See the scan below.

Reproduce

Python, against main and against this branch, using a record already on main:

import json

from agentrust_trace.sign import verify_record

path = "examples/verifier-compatibility/01-known-version-verified.json"
text = open(path, encoding="utf-8").read()
assert text.count('"iat": 1785000000,') == 1
for spelling in ("1785000000", "1785000000.0", "1.785e9"):
    doc = json.loads(text.replace('"iat": 1785000000,', f'"iat": {spelling},'))
    try:
        verify_record(doc["record"], doc["trusted_key"], now=1785000100)
        print(f"{spelling:>14}  verified")
    except ValueError as exc:
        print(f"{spelling:>14}  rejected: {exc}")
main (1adbe20)
    1785000000  verified
  1785000000.0  rejected: record has no valid integer 'iat' for freshness check
       1.785e9  rejected: record has no valid integer 'iat' for freshness check
this branch
    1785000000  verified
  1785000000.0  verified
       1.785e9  verified

JavaScript, Node.js 20 or later, nothing to install:

node -e 'for (const t of ["1785000000", "1785000000.0", "1.785e9"]) console.log(t, JSON.parse(t), Number.isSafeInteger(JSON.parse(t)))'
node --test examples/number-spelling/spelling.test.mjs

The first line prints the same number three times. The test file (9 tests) checks that
the spelling does not survive String(Number(x)), the serialization RFC 8785 adopts,
walks the safe-integer boundary as the double sees it, and verifies every signature in
the set with node:crypto over a minimal RFC 8785 form of each record.
tests/test_integer_by_value.py runs it when Node.js 20 or later is on the path.

Vectors

examples/number-spelling/:

Fixture Member Written as Expected
01-integer-spelling-verified.json iat 1785000000 verified
02-fraction-spelling-verified.json iat 1785000000.0 verified, 01's signature
03-exponent-spelling-verified.json iat 1.785e9 verified, 01's signature
04-fractional-value-rejected.json iat 1785000000.5 rejected, not an integer value
05-fractional-value-exponent-spelling-rejected.json iat 17850000005e-1 rejected, not an integer value, 04's signature
06-above-range-integer-spelling-rejected.json appraisal.timestamp 9007199254740992 rejected, outside the range
07-above-range-exponent-spelling-rejected.json appraisal.timestamp 1.0e+21 rejected, outside the range

01 reuses the record, key and signature published in
examples/verifier-compatibility/01-known-version-verified.json, so the set
introduces no new signing key. Every signature in the set is valid, so a rejecting
vector is rejected for the rule it names and nothing else. The range vectors use
appraisal.timestamp, which no freshness rule reads.

Two vectors per rule, each pair chosen so that a plausible shortcut passes one and
fails the other: refusing an exponent, or accepting .0 by stripping it from the text,
fails 03 and not 02; finding a fraction by looking for a decimal point accepts 05; and
leaving the range to the rfc8785 package rejects 06 and verifies 07.
tests/test_integer_by_value.py runs those shortcuts over the set and checks each is
caught where the README says it is.

Scan: which published records change verdict

examples/number-spelling/scan_published_numbers.py reads every number's spelling
through json's parse_int and parse_float hooks, in *.json files and in fenced
json blocks in Markdown, and reports every integer-typed member written with a
fraction or an exponent. Integer-typed members are read from schema/*.json, plus
cnf.jwk members and the two members typed in prose (observed_at in the bridge
profile, tool_catalog.tool_count in server provenance).

python examples/number-spelling/scan_published_numbers.py \
  examples spec docs schema \
  /path/to/trace-tests-checkout-at-3af2b53 \
  /path/to/trace-registry-checkout
Root Documents Numbers Integer-typed Written with a fraction or an exponent
examples/ (this repo) 202 1064 453 0
spec/, docs/, schema/ (this repo) 19 86 11 0
trace-tests @ 3af2b53 16 47 32 0
trace-registry @ d8fa885 26 64 9 0
Total 263 1261 505 0

No number anywhere in those roots is written with a fraction or an exponent, integer
member or not. The scan skips examples/number-spelling/, whose vectors are
re-spelled on purpose; pointed at that directory it finds exactly the five re-spelled
members, which is the check that it finds what it looks for. Five Markdown blocks in
spec/ and docs/ are fragments that do not parse; none contains a number written
with a fraction or an exponent.

Current behavior of both implementations

Checked against main at 1adbe20, the base of this branch:

  • This repository's reference library verifies 01, rejects 02 and 03 at the
    freshness check (record has no valid integer 'iat' for freshness check), and
    rejects 04 to 07 at the schema. With this change, 01 to 03 verify and 04 to 07 are
    rejected at the schema with the same messages as before.
  • The TypeScript verifier under review in feat(trust-record-verify): a TypeScript verifier for TRACE v0.2, scored against the Python one #376 (at 01ca3a5) already decides by
    value, by construction: trust-record-verify/src/verify.ts lines 324 to 327 test
    iat with Number.isSafeInteger; trust-record-verify/src/jcs.ts lines 64 to 76
    refuse any integer-valued number past the range with canonicalization_failed,
    whatever it parsed from; and the schema validators compiled by
    trust-record-verify/scripts/generate-validators.mjs (line 27) test integer as
    !(data % 1), a test on the value. Run over the seven vectors, its verifyRecord
    verifies 01 to 03, and rejects 04 and 05 with schema_invalid ("at iat: must be
    integer") and 06 and 07 with schema_invalid ("at appraisal.timestamp: must be <=
    9007199254740991").

So the two implementations already disagreed on 02 and 03 before this proposal, and
this change brings the reference to what the TypeScript verifier does.

Out of scope (follow-ups, not folded into this proposal)

  • Server provenance (src/agentrust_trace/provenance.py): issued_at and
    tool_catalog.tool_count keep their type tests, and
    tests/test_provenance_tool_count.py pins 2.0 as a malformed count. That profile
    signs under section 3.2, so the rule arguably reaches it, but reversing a pinned
    behaviour belongs with that profile.
  • Registry anchor format (anchor_bytes, spec/registry-anchor-v1.md §1): the
    anchor leaf is sorted-key JSON, not RFC 8785, and excludes every non-integer number by
    type, 1.0 included, with a test pinning it. Whether that profile should read 1.0
    as 1 is its own question.
  • Producer guidance. JSON Schema 2020-12 Core §6.3 says integer JSON numbers SHOULD
    NOT be encoded with a fractional part. Whether TRACE should say the same to producers
    is left open; nothing here requires it.
  • Verifier options such as now and max_age_seconds are caller configuration,
    not record data, and keep their integer type checks.

Type of change

  • Editorial (typo, link fix, clarification: no normative effect)
  • Non-breaking spec change (new optional field, new platform profile, informative addition)
  • Breaking spec change (requires 14-day comment period and Project Lead sign-off)
  • Schema change
  • Example addition

This changes normative text in section 3.2.2; breaking or non-breaking is left to you,
see "Change classification" above. No schema file changes: the schemas already read
integers this way.

Spec section

§3.2.2 (Mandatory signature and freshness binding), a new paragraph after "What the
rule covers".

Checklist

  • DCO sign-off on all commits (git commit -s)
  • CHANGELOG.md updated
  • Change marked with <!-- CHANGED: #247 - integer-valued members are decided by value, not by spelling --> in spec text
  • Backward compatibility statement included (for breaking changes): the scan above is the evidence; classification is yours

Context

Requested by @imran-siddique on #247, who asked for this as a separate proposal with
Python and JavaScript tests, including boundary cases. Proposed by @chernistry.

…d JavaScript tests (agentrust-io#247)

JSON has one number type, and RFC 8785 writes a number by its value, so
an iat written 1785000000, 1785000000.0 or 1.785e9 has one pre-image and
one signature. The schemas already decided `integer` by value, as JSON
Schema 2020-12 defines it. verify_record's freshness check tested for a
Python int, and json.loads returns a float for the other two spellings,
so a correctly signed record that had passed the schema was rejected.

Add "What counts as an integer" to section 3.2.2: whether a number is an
integer, and whether it is inside the safe-integer range, is decided by
its value, the IEEE 754 double RFC 8785 serializes, and a verifier must
make both decisions on that value.

In the reference, _integer_value decides a member by value and names the
rule a value fails. The iat freshness check and the bridge's
authorized_at, expires_at and transcript.after.observed_at checks use it.
_canonical_bytes refuses a whole number past the safe-integer range
written as a float, as rfc8785 already refused one written as an int;
both adapters' transcript hashes now go through it. The model reads a
whole float as the integer it is, and refuses a numeric string, which
the schema never accepted.

Add examples/number-spelling/: seven signed vectors (three spellings of
one iat with one signature, a fractional value and a value past the range
each rejected in two spellings), their generator, a node:test file with
no dependencies, and a scan of published records. Across this
repository, trace-tests at 3af2b53 and trace-registry, no integer-typed
member is written with a fraction or an exponent, so no published
verdict changes.

Add tests/test_integer_by_value.py and register the set with the
adequacy checks.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
@chernistry
chernistry requested review from a team and lywinged as code owners September 23, 2026 13:12

Copy link
Copy Markdown
Member

Alex, the by-value direction matches my ruling on #247. Please resolve the scope mismatch before implementation approval: the new paragraph reads broadly, but server provenance retains type-based checks for issued_at and tool_count. Either align that profile with tests or explicitly bound the new rule and track the remaining alignment separately. Keep registry-anchor encoding separate. Also document the Python API compatibility changes, including newly rejected numeric strings and out-of-range whole floats; unchanged schemas alone do not make those changes non-breaking.

…overs; document the Python API changes (agentrust-io#247)

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

@lywinged lywinged left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at 2778d94, against its base 1adbe20 and main at 5222ed4.

The reproducer in the description gives the output shown there on the base and on this head; current main still rejects 1785000000.0 and 1.785e9. The seven vectors verify and reject as the table says; 02 and 03 carry 01's signature and 05 carries 04's. The scan reproduces here, for trace-tests at 3af2b53 and for trace-registry at d8fa885. Full suite 2340 passed and 6 skipped; the JavaScript test passes 9 of 9.

I also ran the suite against twelve plausible edits: accepting a boolean, dropping or narrowing either range check, truncating a fraction, walking dicts but not lists, reading numeric strings again, keeping a whole float as a float, and putting either adapter back on rfc8785.dumps. Eleven fail a test. Three things before approval:

  1. The scope sentence and the code disagree about the provenance record. The description says the whole-float refusal applies "in any object the library signs or digests", and that the provenance profile "signs under section 3.2, so the rule arguably reaches it"; spec/server-provenance-v1.md line 109 agrees that its signing "follows TRACE v0.2 §3.2 exactly, including the canonicalization". The scope sentence added in 2778d94 says the provenance record "is not changed by this paragraph", but provenance.sign_record and provenance.verify_record canonicalize through _canonical_bytes (lines 342 and 483), so a record carrying 1e21, 9007199254740992.0 or -1e21 in an extension member, signed and verified on the base, is refused by verify_record here. The sentence also does not name spec/server-provenance-v2.md, on main since #409. Either settles it: drop the list after the colon, so the paragraph governs everything the rule above covers, provenance included for the range, which also takes in section 3.1.4's transcript_digest; or keep provenance outside and give it a canonicalization path without the new refusal. Whether provenance's issued_at and tool_catalog.tool_count are decided by value is the question the comment above asked to be tracked separately, and there is no issue for it yet.

  2. The CHANGELOG's Python API list needs three corrections. Item (2) names sign_claim, which does not exist; the signing functions are sign.sign_record, provenance.sign_record and intent_bridge.sign_bridge. It says the callers raise rfc8785.IntegerDomainError, but digest_jcs and sign_bridge raise IntentBridgeError and the provenance functions raise ProvenanceError. It also omits point 1's provenance change.

  3. Rebasing onto main has one catch. #418 wrapped rfc8785.dumps in _canonical_bytes so that nesting past the stack is reported as a CanonicalizationError. With _refuse_whole_floats_out_of_range(d) ahead of that try, the walker's own RecursionError comes out first, and two of #418's tests fail: test_verify_bridge_refuses_a_deeply_nested_tool_call_with_intent_bridge_error and test_provenance_verify_record_refuses_a_deeply_nested_member_with_provenance_error. With the call inside the try, the merged tree passes, 2526 passed and 6 skipped.

Tests to add, not blocking: below the range, _canonical_bytes's own check is tested only with -9007199254740992, which rfc8785 already refuses. Four edits to that check pass the suite: checking only the upper bound, the one of the twelve above that passed; moving either bound in by one; and moving the lower bound out by one. Running -9007199254740992.0, -1e21, 9007199254740991.0 and -9007199254740991.0 through _canonical_bytes pins all four.

…io#247)

main gained agentrust-io#418, which wraps rfc8785.dumps in _canonical_bytes so that a
value nested past the interpreter stack is reported as a
CanonicalizationError. The whole-float range walk recurses over the same
containers. Ahead of that guard its own RecursionError came out first, and
two of agentrust-io#418's tests failed; inside it the merged tree passes.

CHANGELOG.md: both sides kept, this entry first under Unreleased.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
…each caller's error; pin both bounds (agentrust-io#247)

Review points on agentrust-io#404.

Scope. "What counts as an integer" said the MCP Server Provenance Record was
not changed by it, while provenance.sign_record and provenance.verify_record
canonicalize through _canonical_bytes and so refuse a whole float past the
safe-integer range. The paragraph now governs every object the range rule
covers and says so: for the range that includes the provenance record, v1
and v2, which is signed under section 3.2, and the transcript behind section
3.1.4's transcript_digest. It retypes no provenance member: issued_at and
tool_catalog.tool_count keep their type-based checks, and whether they are
decided by value is left as a separate question.

CHANGELOG. The Python API list named sign_claim, which does not exist, and
gave one exception for every caller. It now names sign.sign_record,
revocation.bundle_digest and the adapters' transcript hashes
(rfc8785.IntegerDomainError), intent_bridge.digest_jcs, sign_bridge and
verify_bridge (IntentBridgeError), and provenance.sign_record and
verify_record (ProvenanceError), and records the provenance change: a record
carrying 1e21, 9007199254740992.0, -1e21 or -9007199254740992.0 in a member
its verifier does not type signed and verified through 0.11.0 and is refused
now.

Tests. -9007199254740992.0 and -1e21 join the out-of-range spellings, and
9007199254740991.0 and -9007199254740991.0 are canonicalized, so the check in
_canonical_bytes is held at both bounds from both sides. Each signing
function is run with a whole float past the range and checked for its own
error class. Provenance records of both formats are refused at signing and at
verification, sign and verify with a whole float inside the range, and keep
refusing issued_at and tool_count written as floats.

Scan. Re-run at today's revisions: trace-tests at da4369b and trace-registry
at e26b85a. 1,341 numbers, 549 integer-typed, none written with a fraction
or an exponent; the one other number written that way is 1e400 in a
trace-tests vector built to be refused.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
@imran-siddique

Copy link
Copy Markdown
Member

@chernistry the three asks from 23 September are met at 08b1d76: the rule reaches provenance.py, registry-anchor encoding is untouched, and the CHANGELOG names both newly refused inputs as a Python API break. #432 and #401 merged today and this now conflicts on CHANGELOG.md, intent_bridge.py and tests/test_adequacy_all_sets.py. Rebase onto main and I will carry the paragraph and merge once it is green.

Conflicts in CHANGELOG.md, src/agentrust_trace/intent_bridge.py and
tests/test_adequacy_all_sets.py, after agentrust-io#401, agentrust-io#432 and agentrust-io#451.

Not mechanical, so listed:

- intent_bridge.py. agentrust-io#432 moved the observation out of the bridge transcript
  into the successor envelope and its signed artifact. The envelope's
  `observed_at` keeps main's wording and this branch's decision by value.
  The artifact's own `observed_at`, which main added with a type test, is
  decided by value as well: the artifact is signed over its RFC 8785 form
  and is compared with the envelope, so `150.0` there was a spelling the
  signature covers and only the type test refused. Each refusal names its
  reason, as the envelope's does.
- tests/test_integer_by_value.py. The two observation-time tests are
  rewritten against sign_successor_artifact and verify_successor_artifact,
  with the same refusals for the artifact's copy of the time.
- examples/number-spelling/scan_published_numbers.py. `observed_at` is read
  from schema/pic-trace-successor-v1.json now, so it leaves the short list
  of members named only in prose. The scan's member list gains the integer
  members of the schemas added on main; it still finds no published example
  the rule changes.
- tests/test_adequacy_all_sets.py keeps both sets, number-spelling and
  signature-encoding.
- CHANGELOG.md: main's entries, then this proposal's, with the bridge
  sentence naming the successor envelope and artifact.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
@imran-siddique
imran-siddique merged commit fe9584f into agentrust-io:main Oct 5, 2026
13 of 14 checks passed
chernistry added a commit to chernistry/trace-spec that referenced this pull request Oct 7, 2026
…est, profile on the result, symbol keys on arrays

Four points from review of 0655596.

isPlainObject accepted Object.create(Object.create(null)): a prototype
with a null prototype is not Object.prototype. It now accepts a null
prototype or a realm's Object.prototype, recognised by its own
constructor being that realm's Object, and nothing else; cross-realm
{} and Object.create(null) stay accepted. verifyRecord read cnf, iat and
runtime through the prototype chain while the signature pre-image is
built from own members; every read is now own(), and an inherited cnf
or iat stands for nothing. Tests cover the one-hop prototype in both
realms, and a record from a realm whose Object.prototype carries a
non-enumerable iat or cnf, which the schema sees and verification does
not.

VerificationResult reports profile and the complete acceptedProfiles
set, as section 3.3 requires; ACCEPTED_PROFILES is exported. Both
runners of the differential emit the two fields and compare.py folds
them into the verified equivalence.

canonicalJson refused a symbol-keyed member on an object and not on an
array; the array branch now refuses it too, with a test.

The differential against current main: 1643 of 1644 identical. The
ledger entry for iat written as a float or with an exponent is unreached
since agentrust-io#404 and leaves the ledger; README, CHANGELOG and the ledger note
carry the figures.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
imran-siddique added a commit that referenced this pull request Oct 13, 2026
…ed against the Python one (#376)

* feat(trace-verify-ts): add a TypeScript verifier for TRACE v0.2 records

A specification with one implementation says whatever that implementation
does. This is a second one, written from spec/trace-v0.2.md and schema/,
and it runs on WebCrypto alone: no network, no filesystem, no platform
module and no runtime dependency, so a record can be checked where there
is no Python runtime.

What it implements, with the rule each check comes from:

  - the profile cutover of section 2, with the superseded v0.1 identifier
    rejected under its own code rather than as an unknown profile;
  - RFC 8785 canonicalization, including section 3.2.2's requirement to
    reject an integer outside the JCS safe range and the I-JSON rule that
    a lone surrogate has no canonical form;
  - Ed25519 over the pre-image of section 3.2.2, the record with its
    signature member absent;
  - canonical unpadded base64url for the signature and for JWK members
    (RFC 4648 sections 5 and 3.5), so one byte string has one spelling;
  - RFC 7638 thumbprints, and the confirmation-key binding of section
    3.2.2;
  - freshness and nonce, with the bounds section 3.2.2 states;
  - the three revocation outcomes of section 3.2.3 against a bundle the
    caller passes in, with a bundle defect reported as an outcome rather
    than thrown;
  - the chain digest of section 3.1.3, over the complete parent record
    with its signature present.

The schemas are compiled ahead of time with ajv, so nothing is evaluated
or fetched at run time, `pattern` is read as ECMA-262 as JSON Schema
2020-12 requires, and `format: uri` is asserted rather than annotated.
The generator records the SHA-256 of every schema file it read.

The order of the checks is the Python implementation's, so both report
the same first failure for the same record.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* test(trace-verify-ts): cover the units, this repository's vectors and the build

Three files. The unit tests pin the canonical forms and the refusals that
have a published answer: RFC 8785 member ordering by UTF-16 code unit,
the number and escape forms, the values that have no canonical form, the
base64url alphabet and pad-bit rules, and the RFC 7638 section 3.1
example thumbprint. The vector tests run the canonicalization-boundary
and revocation-bundle corpora through the verifier and check the outcome,
the cause and every evidence member each vector states, plus the chain
digest of every link in the delegation corpus.

The third file tests the build rather than the logic: the compiled
output imports nothing, so a platform module or a network call cannot
arrive unnoticed; the schema digests the validators were generated from
still match schema/; and every exported function refuses junk with the
package's own error type rather than a TypeError, which is what a caller
written against the documented contract catches.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* test(trace-verify-ts): score the two implementations against each other

A second implementation is only evidence if something compares them. The
harness generates a corpus, runs both sides over it and reports every
case where they disagree.

The corpus holds each record as text, not as a parsed object, because
several cases exist to probe a difference that only survives in text: an
iat written 1785000000.0, a lone surrogate in a string, a signature
spelled with padding. Both runners parse the same bytes with their own
JSON parser, which is part of what is compared.

It covers a signed record under 24 verifier configurations with 59
mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, the chain
digest of every record in the delegation corpus, this repository's own
vectors, and the published conformance vectors from a pinned checkout
whose files are recorded by SHA-256 rather than vendored.

The Python side reports a rejection as an exception, and two distinct
checks raise the same message from the same helper called twice, so the
classifier reads the traceback to ask which call site it came from, and
looks the line numbers up in the installed source rather than writing
them down. An exception it does not recognise is reported, not guessed.

1644 cases: 1280 agree exactly, including every published vector, every
thumbprint and every chain digest. The other 364 are 13 classes argued in
differential/known-divergences.json, which matches on both the case and
the pair of reported reasons, so a new disagreement inside a listed
family is reported rather than absorbed. Nothing was adjusted to match
the other side.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* ci(trace-verify-ts): run the unit tests and the differential harness

Two jobs. The unit job builds and tests on Node 20, 22 and 24: the floor
is what the package declares and the ceiling is current, so a runtime
leaving that matrix is a supported-versions decision rather than a CI
adjustment. The differential job installs the Python implementation from
this tree, checks out the published conformance vectors at a pinned
commit, runs both sides over the generated corpus and fails on any
disagreement that is not in the ledger.

The vector checkout is allowed to fail: the corpus without it is this
repository's own vectors plus the generated matrix, which is the larger
part, and a verifier's CI should not go red because another repository
is unreachable. Actions are pinned by commit, as the other workflows here
are.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* docs(changelog): record the TypeScript verifier and what the differential run found

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* fix(trace-verify-ts): accept uppercase IPvFuture version flag

Signed-off-by: Imran Siddique <imran.siddique@opaque.co>

* differential: assert the published-vector count and hold the ledger in both directions

The workflow tested for the trace-tests checkout at a path relative to
trace-verify-ts, so the published-vector group never ran in CI: the one
run logged 1632 cases where 1644 were expected, and nothing failed. The
checkout is now required, generate_cases.py refuses a root it cannot
read or that yields no record, and compare.py --expect-external N fails
unless exactly N cases came from it. The manifest of vector digests is
always written, printed with the count, and uploaded with the report.

The ledger failed in one direction only: a declared divergence that
stopped being reached lowered the number the run leads with and exited
0. Each entry now declares how many cases land on it, and an entry, a
pair or a count the run does not bear out fails the run. Six pairs that
attributed a signature or schema divergence to the bundle or trusted-key
context move to the entries whose fix alone resolves them; the 364 is
unchanged, its split is now 20/14/23/23/24/96/30/69/23/23/2/16/1.

Setting the RFC 4648 unused-bits mask to zero, which one unit test
already caught, now also fails the differential: entries 2, 3, 4 and 12
unreached, one pair of entry 1 never observed.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* differential: judge a published vector at a valid now

generate_cases.py set now from the vector's own iat whenever that was an
int, so a vector whose point is an iat outside the safe-integer range
made the TypeScript verifier refuse the caller's argument before it read
the record. now is taken from iat only inside [0, 2^53-1]. Against
trace-tests cf30e9b the corpus goes from 1660/1295/364/1 to
1660/1296/364/0; the pinned corpus is unchanged.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* differential: a text either runner cannot parse is a verdict, not a crash

oracle.py parsed record_json, revocation_bundle_json and value_json
outside any try, so a text json.loads refuses, or one nested past the
interpreter's stack, took the process down with the output file however
far it got, where run.mjs reports parse_error and exits 0. Every runner
on the Python side now reports parse_error for such a text, a
RecursionError inside rfc8785 classifies as canonicalization_failed,
which is what the other side reports for the same input, and run.mjs
gains the same parse arm on its thumbprint and chain-digest runners.
Latent in the committed corpus, since all 1644 texts parse; live for the
external one, which is arbitrary JSON from another repository.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* differential: classify the accepted-profiles refusal main now raises

Since #116 the reference refuses a record whose eat_profile is outside
the verifier's accepted set with a new message. The oracle mapped only
the previous wording, so after merging main the 25 m16-profile-unknown
cases reported unclassified against the TypeScript profile_unsupported.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* docs(trace-verify-ts): four acceptances rather than five, and what two sentences hold

The changelog entry and the description said five of the 13 divergence
classes were records the reference accepts. Four are (entries 3, 8, 9,
10); the other two are rejections of a different shape; and the
"patterned field" clause covered signature, where the reference rejects.
The clause now names subject (U+000A, U+000D, U+2028) and
appraisal.verifier (format: uri, U+000A). The schema-digest test is
described by what it holds, that a build came from exactly the schema
bytes it names, rather than by a rebuild case no invocation in the
repository reaches. The canonicalization divergence is named as its
class, magnitude 2^53 and above, of which 1.0e+21 is the corpus's
instance. A README paragraph says what the differential cannot observe:
a constant-time property, and a length function the schema does not
exercise.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* refactor(trust-record-verify): rename the package to @agentrust-io/trust-record-verify

The package, its directory, the workflow and its path filters, the README and
the changelog entry take the new name. The corpus seed in
differential/generate_cases.py keeps its original spelling, as the named
constant CORPUS_SEED_DOMAIN: every key in the 1644-case corpus derives from it,
so changing it reissues the corpus and moves outcomes that
known-divergences.json pins by case and by count.

Differential against agentrust-trace 0.10.0 after the rename: 1644 cases,
1280 identical, 364 known, 0 unexpected; ledger 13 entries, 71 pairs,
0 unreached; 12 published vectors, manifest sha256:869550bf03073ba3.
Unit tests 74/74 on Node 25.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* test(differential): retire the five divergences the reference no longer has

#383, #386, #387 and #388 fixed the four defects this harness reported, so the
cases that recorded them stopped diverging and the ledger failed closed on five
entries no case reached: the lone-surrogate revocation bundle (#382), `subject`
and `appraisal.verifier` under ECMA-262 pattern semantics (#379), the
IPv4-in-IPv6 literal with a leading zero (#380), and the non-ASCII nonce (#381).
They leave the ledger rather than stay as claims nothing checks.

Four counts move with them. The `c23-bundle-lone-surrogate` context used to
diverge on every mutation and matched the first entry; now that it verifies like
any other context, its cases fall through to the mutation families:
m03 23 to 24, m04 23 to 24, m20/m21 30 to 32, m46/m47 16 to 17.

The ledger's `reference` and the README's table no longer say 0.10.0 without
qualification, because the behaviour under test is the tree's and the four fixes
are not in that release.

1644 cases, 1412 identical, 232 known, 0 unexpected; ledger 8 entries, 33 pairs,
0 unreached; 12 published vectors, manifest sha256:869550bf03073ba3.

Signed-off-by: chernistry <sanderchernitsky@gmail.com>

* fix(trust-record-verify): tighten three input guards

encodeBase64url takes a Uint8Array and nothing else that views a buffer:
a Uint16Array iterates 16-bit values, which the byte loop encoded as the
wrong bytes, and a DataView is not iterable, which reached a native
TypeError instead of the package's own error.

canonicalJson refuses an object with a symbol-keyed member instead of
dropping it: Object.keys never listed the symbol, so a value the module
documents as unsupported canonicalized to a form missing a member.

verifyDelegationLink holds the same plain-object contract for its options
as verifyRecord; an array, a Date or a class instance was accepted before.

A test for each, with the same-shaped value that is accepted beside it.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* differential: the schema location and the key source are part of the verdict; duplicate ids fail the run

compare.py treated two schema_invalid verdicts as agreeing whatever member
each side faulted, and only listed the difference as commentary. The check
order is shared, so the first member faulted must be the same one: the
location is now part of the summary a case has to match, and a difference
there is a divergence to be argued in the ledger like any other. At this
head every one of the 600 schema failures agrees on the member.

VerificationResult.trustedKeySource was outside the comparison, so a
regression in it would pass as long as the thumbprint matched, which it
does whenever the caller passes the key the record embeds. run.mjs now
reports it; oracle.py states the same fact the way verify_record resolves
it, since the Python result carries no such member; compare.py matches
the two. A unit test pins the case the thumbprint cannot tell apart.

Both runners key verdicts by case id, so a second case under one id
replaced the first without a trace, and the external ids fold "/" into
"-", which is one way two files can collide. generate_cases.py fails on a
duplicate id and compare.py fails on a verdict file that repeats one.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* ci(trust-record-verify): run the differential on spec changes too

A normative change under spec/ can move the verdict a case is expected
to produce without touching the schemas, the examples or either
implementation. The path filter listed all of those and not the
specification itself, so such a change skipped the differential.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* docs(trust-record-verify): align the README and the changelog with the ledger at this head

The README said the 136 remaining cases were seven classes each appearing
once per verifier configuration; three do (24 each), the others land on
32, 17 and 14 cases, and one is in the RFC 8785 corpus, not the mutation
matrix. The retired entries were listed as "six" in five items, with two
folded into one. The table now carries each entry with its pinned count,
and the six retired entries are six items. The changelog entry still gave
the first run's figures (1280 identical, 364 in 13 classes); it now gives
this head's (1508, 136 in 7) and says what left and what remains. Both
also say that the differential compares the location of a schema failure
and the source of the verifying key.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* docs(trust-record-verify): state the section 3.2.3 subset this package implements

The revocation consumer checks the bundle signature and applies a
statement naming the trusted key as the section's fallback for a record
without a usable receipt. It does not verify a statement's own signature
against the section 3.2.1 hierarchy, and it takes no inclusion entry ID,
so the last_valid_entry_id rule is not applied. The module header said so;
the README's limits and the option's own documentation now say it too,
and name it as the same subset the Python implementation documents.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* fix(trust-record-verify): re-baseline the differential on the 0.11.0 reference; decode the signature where it does

Against main the differential no longer held. The reference's decoder refuses
base64url that is not canonical since #418 (0.11.0), with two messages the
oracle did not classify, and it reports a padded signature or one in the
standard alphabet at its decoding step, which comes before its schema check.
This implementation consulted the schema first. The run read 1644 cases,
1429 identical, 57 known, 158 unexpected, 4 ledger entries unreached.

- oracle.py classifies "signature is not canonical base64url" and
  "JWK 'x' is not canonical base64url".
- verifyRecord decodes the signature ahead of the schema, which is the order
  the module header already promised. A padded signature, one in the standard
  alphabet, one with a trailing newline and one of an impossible length are
  signature_malformed on both sides; the empty string decodes to no bytes and
  is still refused by the schema's pattern on both sides. A test pins all
  five.
- Five ledger entries that no case reaches are retired: non-zero unused bits
  in a signature, a truncated signature, a non-canonical x in cnf.jwk or in
  the caller's key (the reference refuses them now), and the trailing newline
  (the order here moved).

Now: 1644 cases, 1611 identical, 33 known, 0 unexpected, 2 ledger entries,
none unreached, 12 published vectors. README and CHANGELOG carry those
figures, and the CHANGELOG entry is back under Unreleased, where the merge
with main had left it inside the 0.11.0 section.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* fix(trust-record-verify): type tests that do not depend on the realm; run the differential on dependency changes

Review points on #376.

encodeBase64url tested `instanceof Uint8Array`, which is false for a
Uint8Array made in another frame or worker. It now reads the typed array's
[[TypedArrayName]] through the %TypedArray% toStringTag getter: true for a
Uint8Array of any realm, Buffer included, and false for a Uint16Array, a
DataView, an ArrayBuffer, an array, and an object that only carries its own
Symbol.toStringTag, which a test on Object.prototype.toString would accept.

isPlainObject compared the prototype with this realm's Object.prototype, so
a plain object from another realm was refused: the options of
verifyDelegationLink and verifyRecord, and equally a record parsed by another
frame's JSON.parse, a JWK, and any object reaching the canonicalizer. It now
accepts an object with no prototype or with a prototype that has none of its
own. Arrays, Dates, class instances and Object.create({}) are refused as
before, from either realm.

The revocation store was tested with `instanceof Set`, the same fault; it now
asks the Set size getter, which answers for a Set of any realm and throws for
anything else.

Tests build each value in a second realm with node:vm: a record, its options
and a revocation set from there verify as the same values do here.

The workflow's path filters gain requirements/** and pyproject.toml, which
the differential job installs the reference from.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* fix(trust-record-verify): own-member reads, a stricter plain-object test, profile on the result, symbol keys on arrays

Four points from review of 0655596.

isPlainObject accepted Object.create(Object.create(null)): a prototype
with a null prototype is not Object.prototype. It now accepts a null
prototype or a realm's Object.prototype, recognised by its own
constructor being that realm's Object, and nothing else; cross-realm
{} and Object.create(null) stay accepted. verifyRecord read cnf, iat and
runtime through the prototype chain while the signature pre-image is
built from own members; every read is now own(), and an inherited cnf
or iat stands for nothing. Tests cover the one-hop prototype in both
realms, and a record from a realm whose Object.prototype carries a
non-enumerable iat or cnf, which the schema sees and verification does
not.

VerificationResult reports profile and the complete acceptedProfiles
set, as section 3.3 requires; ACCEPTED_PROFILES is exported. Both
runners of the differential emit the two fields and compare.py folds
them into the verified equivalence.

canonicalJson refused a symbol-keyed member on an object and not on an
array; the array branch now refuses it too, with a test.

The differential against current main: 1643 of 1644 identical. The
ledger entry for iat written as a float or with an exponent is unreached
since #404 and leaves the ledger; README, CHANGELOG and the ledger note
carry the figures.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

* fix(trust-record-verify): compile the schema validators with ownProperties

Review of e4300b7: the signature pre-image is built from a record's own
members, while ajv's required and properties tests and its for-in walks
also saw inherited ones. cnf, iat and runtime were read with own(); the
other required members (subject, model, policy and the rest) were not
held to the same rule by the schema.

The validators are now generated with ownProperties: true, so required
and properties test own properties and every object walk uses
Object.keys. A regression test moves each member of a signed record onto
a cross-realm Object.prototype, enumerable and not: a required member is
schema_invalid, eat_profile is profile_missing, an optional member falls
outside the pre-image and is signature_invalid, and cnf.jwk one level
down is schema_invalid at cnf. Without the option 11 of those cases fail.

Differential against current main: 1643 of 1644 identical, unchanged.

Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>

---------

Signed-off-by: chernistry <sanderchernitsky@gmail.com>
Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
Co-authored-by: Imran Siddique <imran.siddique@opaque.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants