Call Signature Reference

The call signature is a 256-bit BLAKE3 hash that uniquely identifies one tool call invocation. It provides exactly-once execution semantics across all SDKs and services.


Algorithm

Input byte stream

[session_id: 16 bytes]
[step_index: 4 bytes LE]
[tool_name_len: 4 bytes LE][tool_name: N bytes UTF-8]
[canonical_args_len: 4 bytes LE][canonical_args: M bytes UTF-8]

Output

64-character lowercase hex string (BLAKE3-256 hash of the above byte stream).

Implementation

Rust (undolog-types/src/effect.rs):

let mut hasher = blake3::Hasher::new();
hasher.update(session_id.as_uuid().as_bytes());
hasher.update(&step_index.to_le_bytes());
let name_bytes = tool_name.as_bytes();
hasher.update(&(name_bytes.len() as u32).to_le_bytes());
hasher.update(name_bytes);
let canon = canonical_json(canonical_args);
let args_bytes = canon.as_bytes();
hasher.update(&(args_bytes.len() as u32).to_le_bytes());
hasher.update(args_bytes);
Self(hasher.finalize().to_hex().to_string())

Python (undolog_sdk/signature.py):

sid = uuid.UUID(session_id)
hasher = blake3.blake3()
hasher.update(sid.bytes)
hasher.update(struct.pack("<I", step_index))
hasher.update(struct.pack("<I", len(name_bytes)))
hasher.update(name_bytes)
hasher.update(struct.pack("<I", len(args_bytes)))
hasher.update(args_bytes)
return hasher.hexdigest()

Canonical JSON

Deterministic, sorted-key JSON string suitable for hashing. Produces byte-for-byte identical output across the Rust, Python, and TypeScript SDKs. The Go proxy implements a separate, structurally different canonical JSON: it wraps the call in {"tool_name", "args"} rather than hashing a length-prefixed byte stream. Numbers inside it now follow the same ECMAScript rules described below, but the overall signature is not byte-identical to this contract.

Rules

Rule Description
Key sorting Object keys are sorted lexicographically ascending. Recursively applied for nested objects.
No whitespace Compact representation with no spaces, tabs, or newlines.
Leaf values Strings, booleans, and null use standard JSON serialisation. Numbers follow the ECMAScript JSON.stringify rules (RFC 8785, Section 3.2.2.2) described below.
Non-finite floats NaN, Infinity, and -Infinity are rejected: Python raises ValueError, TypeScript throws TypeError, and Rust rejects them at serde_json::Value construction (Number::from_f64 returns None). Per RFC 8785, non-finite values are not valid JSON and must cause an error.
Negative zero -0.0 serialises as 0, matching ECMAScript JSON.stringify and RFC 8785.
Float notation Fixed notation for values in [1e-6, 1e21); exponential notation elsewhere. Exponential exponents have no leading zeros (1e-7, 1e+21). Matches ECMAScript Number.prototype.toString.
Integers Serialised as-is with no decimal point or exponent (42, 0, -7).
Large integers Integers beyond 2^53 are exact in Python (int) and Rust (i64/u64). TypeScript number values round at 2^53, so callers must pass bigint for exact cross-language parity.
Arrays Preserved in insertion order. Elements recursively canonicalised.

Examples

Input Canonical JSON
{"b": 1, "a": 2} {"a":2,"b":1}
{"z": {"b": 2, "a": 1}} {"z":{"a":1,"b":2}}
[3, 1, 2] [3,1,2]
{"name": "alice", "age": 30} {"age":30,"name":"alice"}
{"v": 1e-7} {"v":1e-7}
{"v": -0.0} {"v":0}
{"v": 1e21} {"v":1e+21}

Implementation references

Language Location
Rust undolog-types/src/effect.rs: pub fn canonical_json(v: &serde_json::Value) -> String
Python undolog_sdk/signature.py: def canonical_json(value: Any) -> str
TypeScript sdks/undolog-ts/src/signature.ts: export function canonicalJson(value: unknown): string
Go (proxy, separate format) services/undolog-proxy/internal/proxy/signature.go: func writeCanonicalJSON(buf *bytes.Buffer, v any) error

BLAKE3 Details

Property Value
Algorithm BLAKE3 (default mode)
Output length 256 bits (32 bytes)
Hex length 64 characters
Character set [0-9a-f] (lowercase)
Language blake3 Rust crate / blake3 Python PyPI package

Cross-language invariant

Same inputs → same 64-char hex signature, regardless of language or platform.


Collision Properties

Property Value
Preimage resistance 256 bits
Collision resistance 128 bits (BLAKE3 birthday bound)
Unique constraint undolog_effect_log.call_signature has UNIQUE index
Conflict strategy INSERT ... ON CONFLICT (call_signature) DO NOTHING
Collision probability Less than 2^-128 for distinct inputs. Sufficient for the application use case.

Input uniqueness

The length-prefixed encoding format prevents boundary attacks where two different (tool_name, args) pairs could produce the same byte sequence without delimiters. For example:

step=1, tool="ab", args="c"     → 01|00:00:00|02:00:00:00|ab|01:00:00:00|c
step=1, tool="a",  args="bc"    → 01|00:00:00|01:00:00:00|a |02:00:00:00|bc

The length prefixes ensure these produce different hashes even though the concatenated raw bytes would overlap.


FNV-1a Advisory Lock Derivation

The call signature is used by the Rust engine to derive PostgreSQL advisory lock keys for concurrent write protection. The Go proxy computes its own canonical JSON for signing but delegates deduplication and advisory locking to the engine via gRPC.

Algorithm

FNV-1a 64-bit hash of the 64-char hex signature string
→ int64 (signed, for pg_try_advisory_xact_lock)

Implementation reference

Language Location
Rust undolog-store/src/effect_store.rs advisory lock acquisition

Lock strategy

1. Compute advisory_lock_key(call_signature)  -- Rust engine
2. SELECT pg_try_advisory_xact_lock(key)
3. If false: retry up to UNDOLOG_LOCK_MAX_ATTEMPTS with UNDOLOG_LOCK_RETRY_MS backoff
4. If still false: return AdvisoryLockTimeout error
5. INSERT INTO undolog_effect_log ... ON CONFLICT (call_signature) DO NOTHING

The advisory lock is a performance optimisation (avoids write conflict rollbacks under high concurrency). The UNIQUE constraint on call_signature is the last-resort safety net.


Database Column

Column Type Constraint
call_signature char(64) NOT NULL, UNIQUE across all partitions

Testing properties

Property Test
Deterministic Same inputs → same output every time
Length Always 64 lowercase hex characters
Step sensitivity Different step_index → different signature
Args sensitivity Different args → different signature
Key order invariance {"b":1,"a":2} and {"a":2,"b":1} produce the same signature
Cross-language Python, Rust, and TypeScript produce byte-identical output for identical inputs. The Go proxy applies the same number rules inside its own {"tool_name", "args"} wrapper format.