Repository navigation
spec(numbers): decide integer-valued members by value, with Python and JavaScript tests (#247) - #404
Conversation
…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>
|
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 |
…overs; document the Python API changes (agentrust-io#247) Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com>
lywinged
left a comment
There was a problem hiding this comment.
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:
-
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.mdline 109 agrees that its signing "follows TRACE v0.2 §3.2 exactly, including the canonicalization". The scope sentence added in2778d94says the provenance record "is not changed by this paragraph", butprovenance.sign_recordandprovenance.verify_recordcanonicalize through_canonical_bytes(lines 342 and 483), so a record carrying1e21,9007199254740992.0or-1e21in an extension member, signed and verified on the base, is refused byverify_recordhere. The sentence also does not namespec/server-provenance-v2.md, onmainsince #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'stranscript_digest; or keep provenance outside and give it a canonicalization path without the new refusal. Whether provenance'sissued_atandtool_catalog.tool_countare decided by value is the question the comment above asked to be tracked separately, and there is no issue for it yet. -
The CHANGELOG's Python API list needs three corrections. Item (2) names
sign_claim, which does not exist; the signing functions aresign.sign_record,provenance.sign_recordandintent_bridge.sign_bridge. It says the callers raiserfc8785.IntegerDomainError, butdigest_jcsandsign_bridgeraiseIntentBridgeErrorand the provenance functions raiseProvenanceError. It also omits point 1's provenance change. -
Rebasing onto
mainhas one catch. #418 wrappedrfc8785.dumpsin_canonical_bytesso that nesting past the stack is reported as aCanonicalizationError. With_refuse_whole_floats_out_of_range(d)ahead of thattry, the walker's ownRecursionErrorcomes out first, and two of #418's tests fail:test_verify_bridge_refuses_a_deeply_nested_tool_call_with_intent_bridge_errorandtest_provenance_verify_record_refuses_a_deeply_nested_member_with_provenance_error. With the call inside thetry, 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>
|
@chernistry the three asks from 23 September are met at 08b1d76: the rule reaches |
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>
…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>
What this changes
JSON has one number type.
1785000000,1785000000.0and1.785e9are threespellings of one value, and RFC 8785 writes all three as
1785000000, so a TrustRecord carrying any of them has one pre-image and one signature. Section 3.2.2 does
not say whether a member typed
integerholds an integer when it is written thesecond 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
jsonschemafollows JSON Schema 2020-12,whose
integermatches any number with a zero fractional part (Validation §6.1.1),so a record with
"iat": 1785000000.0passesschema/trace-claim.jsonas it stands.No schema file changes in this PR.
verify_recorddoes not. Its freshness check testedisinstance(iat, int), andjson.loadsreturns afloatfor the fraction andexponent 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'sauthorized_at,expires_atandtranscript.after.observed_atchecks carried thesame type test.
JSON.parsereturns 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 -->: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.0000000001a fraction, while everyimplementation 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/trace-v0.2.md, section 3.2.2): the paragraph above.src/agentrust_trace/sign.py):_integer_valuedecides a member byvalue, refuses
boolexplicitly, and says which rule failed:... is not an integer value: ...or... outside the safe-integer range .... Theiatfreshness checkuses it.
_canonical_bytesnow refuses a whole number past the safe-integer rangewritten as a float (
1e21,9.007199254740993e15), with theIntegerDomainErrorthat
rfc8785already raised for the same value written as an integer. The section3.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
1e21is an integer outside the range; therfc8785package refuses anout-of-range
intand writes the float as1e+21without complaint.src/agentrust_trace/intent_bridge.py):authorized_at,expires_atandtranscript.after.observed_atare decided by value, with the reason in the error.src/agentrust_trace/adapters/agt.py,sandbox.py): the two transcripthashes go through
_canonical_bytesinstead of callingrfc8785.dumpsdirectly, soevery digest in the package draws the range in the same place.
src/agentrust_trace/models.py):JsonIntreads a whole float as theinteger 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/schema.md): one sentence in the introduction.examples/number-spelling/): seven signed Trust Records, theirgenerator, a README, a JavaScript test and a scan script; see below.
tests/test_integer_by_value.py,tests/test_adequacy_all_sets.py): theboundary 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:
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.
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, andevery integer member is bounded.
Reproduce
Python, against
mainand against this branch, using a record already onmain: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.mjsThe 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:cryptoover a minimal RFC 8785 form of each record.tests/test_integer_by_value.pyruns it when Node.js 20 or later is on the path.Vectors
examples/number-spelling/:01-integer-spelling-verified.jsoniat178500000002-fraction-spelling-verified.jsoniat1785000000.003-exponent-spelling-verified.jsoniat1.785e904-fractional-value-rejected.jsoniat1785000000.505-fractional-value-exponent-spelling-rejected.jsoniat17850000005e-106-above-range-integer-spelling-rejected.jsonappraisal.timestamp900719925474099207-above-range-exponent-spelling-rejected.jsonappraisal.timestamp1.0e+2101 reuses the record, key and signature published in
examples/verifier-compatibility/01-known-version-verified.json, so the setintroduces 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
.0by 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
rfc8785package rejects 06 and verifies 07.tests/test_integer_by_value.pyruns those shortcuts over the set and checks each iscaught where the README says it is.
Scan: which published records change verdict
examples/number-spelling/scan_published_numbers.pyreads every number's spellingthrough
json'sparse_intandparse_floathooks, in*.jsonfiles and in fencedjsonblocks in Markdown, and reports every integer-typed member written with afraction or an exponent. Integer-typed members are read from
schema/*.json, pluscnf.jwkmembers and the two members typed in prose (observed_atin the bridgeprofile,
tool_catalog.tool_countin server provenance).examples/(this repo)spec/,docs/,schema/(this repo)trace-tests@3af2b53trace-registry@d8fa885No 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 arere-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/anddocs/are fragments that do not parse; none contains a number writtenwith a fraction or an exponent.
Current behavior of both implementations
Checked against
mainat 1adbe20, the base of this branch:freshness check (
record has no valid integer 'iat' for freshness check), andrejects 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.
value, by construction:
trust-record-verify/src/verify.tslines 324 to 327 testiatwithNumber.isSafeInteger;trust-record-verify/src/jcs.tslines 64 to 76refuse 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) testintegeras!(data % 1), a test on the value. Run over the seven vectors, itsverifyRecordverifies 01 to 03, and rejects 04 and 05 with
schema_invalid("at iat: must beinteger") 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)
src/agentrust_trace/provenance.py):issued_atandtool_catalog.tool_countkeep their type tests, andtests/test_provenance_tool_count.pypins2.0as a malformed count. That profilesigns under section 3.2, so the rule arguably reaches it, but reversing a pinned
behaviour belongs with that profile.
anchor_bytes,spec/registry-anchor-v1.md§1): theanchor leaf is sorted-key JSON, not RFC 8785, and excludes every non-integer number by
type,
1.0included, with a test pinning it. Whether that profile should read1.0as
1is its own question.NOT be encoded with a fractional part. Whether TRACE should say the same to producers
is left open; nothing here requires it.
nowandmax_age_secondsare caller configuration,not record data, and keep their integer type checks.
Type of change
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
git commit -s)CHANGELOG.mdupdated<!-- CHANGED: #247 - integer-valued members are decided by value, not by spelling -->in spec textContext
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.