# Architecture — agent-scroll-rs
## Goal
Port the [`agent-scroll`](https://github.com/p-vbordei/agent-scroll) v0.1 specification to idiomatic Rust with byte-identical output to the TypeScript reference. The contract is the conformance vector set: same JCS bytes, same SHA-256 hashes, same Ed25519 signatures, verified by `tests/conformance.rs`.
## Module map
| `agent_scroll::canonical` | `src/canonical.ts` | `canonical(&Value)` → JCS bytes; `hash_canonical(&Value)` → `sha256:<hex>` string. **Hosts the `normalize_numbers` workaround — see below.** |
| `agent_scroll::schema` | `src/schema.ts` | `validate_turn` / `validate_sealed_turn`; `VerifyFailure` / `VerifyResult` types (hand-rolled, no `validator` crate needed) |
| `agent_scroll::seal` | `src/seal.ts` | `seal(&turn, sign?)` and `seal_chain(&turns, sign?)` — hash, link via `prev_hash`, optionally Ed25519-sign |
| `agent_scroll::verify` | `src/verify.ts` | `verify(&chain, pubkey?)` → `VerifyResult` with reasons `BadHash` / `BrokenChain` / `BadSignature` / `SchemaViolation` |
| `agent_scroll::serialize` | (in `index.ts`) | `serialize` / `deserialize` round-trip through canonical bytes |
| `agent_scroll::error` | (TS uses thrown `Error`) | `Error` enum via `thiserror` |
| `agent_scroll::lib` | `src/index.ts` | Public re-exports |
CLI is intentionally not ported — the TS reference's `scroll` CLI is a developer convenience, not part of the wire format.
## Dependency choices
| JSON | `serde_json` with `preserve_order` | We need the parsed `Value` to round-trip; `preserve_order` keeps original object key order (JCS will re-sort during emit). |
| JCS encoding | `serde_jcs` (0.1) | RFC 8785 implementation on top of `serde_json`. **See the precision gotcha below.** |
| Hashing | `sha2::Sha256` | Standard, audited. |
| Ed25519 | `ed25519-dalek` (2.x) with `rand_core` | Modern API, fits the TS `nacl.sign` semantics (32-byte seed → keypair, deterministic signatures). |
| Random keys | `rand` (0.8) | `OsRng` for key generation in tests and the example. |
| Base64 | `base64` (0.22) | Standard alphabet, matches TS. |
| Errors | `thiserror` | Idiomatic. |
| Schema regex | `regex` + `once_cell` | Hash format check (`^sha256:[0-9a-f]{64}$`). |
## Critical: `serde_jcs` precision gotcha
RFC 8785 §3.2.2.3 mandates that all JSON numbers be formatted as ECMA-262 `ToString(Number)` — i.e. as IEEE-754 doubles. The TypeScript reference gets this for free because `JSON.parse` coerces all numbers to `f64`.
`serde_jcs` 0.1, however, preserves the integer encoding it gets from `serde_json`. If you feed it a `u64` larger than `2^53` (the f64 safe-integer limit), it emits the exact integer — not the f64-rounded form. This breaks byte-equality.
The agent-scroll spec uses `timestamp_ns: u64` and the C1 vectors include values like `1700000000000000000`, well above `2^53 ≈ 9.007 × 10^15`. So this matters: without intervention, our `canonical()` emits `1700000000000000000` while the TS reference emits `1700000000000000000` formatted as a double (the same string in this case for that magnitude — but the rounding behavior diverges past it, and other fields can break).
**Workaround**, in [`src/canonical.rs`](../src/canonical.rs):
```rust
fn normalize_numbers(value: Value) -> Value {
const SAFE: u64 = 1u64 << 53;
match value {
Value::Number(n) => {
if let Some(u) = n.as_u64() {
if u > SAFE {
return Number::from_f64(u as f64).map(Value::Number).unwrap_or(Value::Number(n));
}
} else if let Some(i) = n.as_i64() {
if i.unsigned_abs() > SAFE {
return Number::from_f64(i as f64).map(Value::Number).unwrap_or(Value::Number(n));
}
}
Value::Number(n)
}
Value::Object(map) => Value::Object(map.into_iter().map(|(k, v)| (k, normalize_numbers(v))).collect()),
Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_numbers).collect()),
other => other,
}
}
```
`canonical()` runs this normalization pass before handing the value to `serde_jcs::to_string`. C1 byte-equality across all 20 vectors is the regression test — if you touch `canonical.rs`, run `cargo test c1_byte_equality` first.
## Byte-determinism invariants
1. **Canonical bytes.** `canonical(turn)` MUST equal the TS reference's `canonical(turn)` byte-for-byte. Verified by C1 across 20 vectors.
2. **Hash strings.** `hash_canonical(turn)` returns `sha256:<lowercase hex>` — always lowercase, always 64 hex chars.
3. **Signature bytes.** Ed25519 over `canonical(turn)` (the unsealed turn — no `hash`, no `sig` keys), base64-standard encoded.
4. **Roundtrip stability.** `canonical(deserialize(serialize(t)))` == `canonical(t)`.
5. **Chain link.** `chain[i].prev_hash == chain[i-1].hash` for `i ≥ 1`. Turn 0 omits `prev_hash`.
## Testing strategy
`tests/conformance.rs` mirrors the TS `conformance/runner.ts`:
- **C1 byte-equality** — load `fixtures/c1-turns.json` (20 turns) and `fixtures/c1-hex.json` (expected hex bytes); assert `hex(canonical(t)) == expected[k]` for every k. **This is the load-bearing test.**
- **C2 mutation** — seal a turn, then (A) flip every hex char of the hash and (B) XOR `0x80` over the first 256 body bytes; `verify` MUST reject every variant.
- **C3 roundtrip** — `serialize` → `deserialize` for plain turns, unsigned sealed turns, and signed sealed turns; canonical bytes must match before and after.
- **C4 chain tamper** — build a 5-turn signed chain, then mutate it three ways: swap order, replace `prev_hash`, replace `hash`. All variants MUST fail `verify`.
Adding a new C1 vector requires adding to BOTH `fixtures/c1-turns.json` and `fixtures/c1-hex.json` (with the hex generated from the TS reference, not from this port).