Skip to content

ADR-0005 — Record encoding details the spec leaves open

Status: accepted · week 1 Fills: SPEC §10.2, §10.5.

Five small decisions. Each one is in the hashed bytes, so each one is frozen once the evidence format is frozen at week 12.

1. Reserved payload keys are underscore-prefixed, and collisions are refused

SPEC §10.2 requires assurance, mode, enforcement, dev and key_id to be inside the payload (so payload_hash covers them) and mirrored as columns. It does not say how they are named inside the payload.

Decision: _assurance, _mode, _enforcement, _dev, _key_id at the top level of the payload. A caller payload already containing one of those keys raises, rather than being silently overwritten — silently overwriting would let a caller's data decide what the record claims about its own assurance level.

A nested _meta object was the alternative. Flat keys keep JCS output shallower and keep the SQL mirror columns a one-to-one map.

2. Signatures are base64url without padding

sig = base64url_nopad(Ed25519_sign(H)), over the 32 raw bytes of the record hash, not over its hex form and not over the canonical payload.

SPEC §10.2 says "base64url" without stating padding. Unpadded, because the = characters carry no information and padded/unpadded is the kind of difference that only surfaces when a third-party verifier is written against the format. qed-verify re-pads on read, so either form is accepted on input and only one is produced.

3. key_id is derived from the public key

key_id = "ed25519:" + sha256(raw_public_key)[:16] (hex).

Consequences: the same key always has the same id, so two copies of a key cannot be labelled differently; an operator cannot name one key as another; and qed-verify recomputes the id from the key material rather than trusting a filename, so a renamed PEM cannot make a record appear to verify under the wrong key.

16 hex characters is 64 bits of the digest — ample for naming within one instance's key set, and short enough to read aloud.

Public keys are written to <dir>/public/<key_id with ':' replaced by '_'>.pem, because : is not a legal filename character on Windows.

4. Timestamps serialise identically in both dump modes

Timestamp is a Pydantic annotated type with when_used="always", so model_dump() and model_dump(mode="json") both produce YYYY-MM-DDTHH:MM:SS.sssZ. Values are required to be timezone-aware, normalised to UTC, and truncated to milliseconds on the way in.

Without this, a record's bytes would depend on which dump mode the caller happened to use, and the bug would appear only when a second code path (the API layer, week 2) wrote records.

5. Floats are canonicalised, not rejected

Payloads in practice carry integers and strings — money is cents, tokens are counts. Rejecting floats outright was tempting and would have removed a class of cross-implementation mismatch, but rule predicates may legitimately compare against a fractional threshold, and those values are hashed as part of the bundle. So JCS implements the full ECMAScript Number::toString conversion in both the producer and the verifier, and tests/core/test_jcs_agreement.py fails the build if the two ever disagree.

Integers outside ±(2⁵³−1) are rejected: a number that cannot round-trip through a double has no business in a hash, and silently rounding it would be the worst available option.