This guide is for developers building a Sanna Protocol-conformant SDK in any language. It covers the critical implementation details that must be exact for cross-language interoperability.
Before writing any code, familiarize yourself with the test fixtures in
fixtures/. These are your ground truth. A conformant implementation must
produce identical hashes and fingerprints for the same inputs.
fixtures/
├── keypairs/ # Ed25519 test keypair (PEM format)
├── constitutions/ # Signed constitutions (YAML + .sig)
├── receipts/ # 4 receipt variants (JSON)
└── golden-hashes.json # Expected hashes for all fixtures
See fixtures/README.md for detailed usage instructions.
All hash computations depend on Sanna Canonical JSON. If your canonicalization diverges from the reference, every hash will be wrong.
Rules:
- Sort object keys by byte-wise comparison of UTF-8 encoded keys
- No whitespace: separators are
,and:(no spaces) ensure_ascii=False— non-ASCII characters appear as literal UTF-8- No HTML escaping —
<,>,&are literal, not\u003c,\u003e,\u0026 - NFC Unicode normalization applied at the hashing boundary
Language-specific notes:
| Language | Canonicalization approach |
|---|---|
| Python | json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) |
| TypeScript/JavaScript | Use the canonicalize npm package or hand-roll with JSON.stringify replacer |
| Go | Custom encoder with SetEscapeHTML(false) — the default encoding/json HTML-escapes <>& |
| Rust | serde_json with to_string (satisfies requirements by default) |
Verification test:
canonicalize({"b": 2, "a": 1}) → '{"a":1,"b":2}'
Compare your output against golden-hashes.json for all fixture inputs.
- NFC normalize the string (Unicode UAX #15)
- Normalize line endings:
\r\nand\r→\n - Strip trailing whitespace from each line
- Strip leading and trailing whitespace from the entire string
- Encode as UTF-8 bytes
- SHA-256 hash
- Return lowercase hex (64 characters by default, or truncated to N)
- Canonical JSON serialize the object
- Pass the resulting string to
hash_text()
The SHA-256 of zero bytes. Used as sentinel for absent fields in fingerprint computation.
EMPTY_HASH = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The fingerprint is a pipe-delimited string of 14 components (expanded from 12 in v1.1), hashed with SHA-256. This is the tamper-evidence mechanism — get it wrong and no receipt will verify.
fingerprint_input = '|'.join([
correlation_id, # 1 — literal string
context_hash, # 2 — hash_obj(inputs), 64-hex SHA-256
output_hash, # 3 — hash_obj(outputs), 64-hex SHA-256
CHECKS_VERSION, # 4 — literal string (currently "6")
checks_hash, # 5 — hash_obj(checks_data), 64-hex SHA-256
constitution_hash, # 6 — hash_obj(constitution_ref minus constitution_approval), or EMPTY_HASH
enforcement_hash, # 7 — hash_obj(enforcement), or EMPTY_HASH
coverage_hash, # 8 — hash_obj(evaluation_coverage), or EMPTY_HASH
authority_hash, # 9 — hash_obj(authority_decisions), or EMPTY_HASH
escalation_hash, # 10 — hash_obj(escalation_events), or EMPTY_HASH
trust_hash, # 11 — hash_obj(source_trust_evaluations), or EMPTY_HASH
extensions_hash, # 12 — hash_obj(extensions), or EMPTY_HASH
parent_receipts_hash, # 13 — hash_obj(parent_receipts), or EMPTY_HASH
workflow_id_hash, # 14 — hash_text(workflow_id), or EMPTY_HASH
])Rules:
- Always exactly 14 pipe-separated fields
- Fields 1 and 4 are literal string values
- All other components are 64-char hex SHA-256 or
EMPTY_HASH - Absent optional fields →
EMPTY_HASH(null and missing are identical) - For fields 7-12, empty objects/arrays also produce
EMPTY_HASH - For field 13,
null→EMPTY_HASHbut[]is hashed as empty array (different hash) - Strip
constitution_approvalfromconstitution_refbefore hashing receipt_fingerprint= first 16 chars ofhash_text(fingerprint_input)full_fingerprint= full 64 chars ofhash_text(fingerprint_input)
Hash each check as a dict with a specific field set:
- Without constitution:
check_id,passed,severity,evidence - With constitution: add
triggered_by,enforcement_level,check_impl,replayable
Hash checks in insertion order (do NOT sort). Null fields must be included
as JSON null (not omitted).
Pure Ed25519 (RFC 8032). No context string, no pre-hashing.
- Signatures: 64 bytes (R ‖ S)
- Base64: RFC 4648 standard with padding (
+,/,=) - Base64url MUST be rejected
- Private keys: PKCS#8 PEM (unencrypted)
- Public keys: SubjectPublicKeyInfo PEM
- Key ID: SHA-256 of the raw 32-byte Ed25519 public key (not DER)
- Create
receipt_signatureblock withsignature: "" - Attach to a copy of the receipt
- Run
sanitize_for_signing()— convert exact-integer floats to integers, reject non-integer floats, NaN, Infinity - Serialize with canonical JSON
- Sign the bytes with Ed25519 private key
- Base64-encode the 64-byte signature
- Replace the empty placeholder with the actual signature
Constitutions are YAML documents validated against schemas/constitution.schema.json.
Required sections: identity, provenance, boundaries
Validation steps:
- Parse YAML
- Validate against JSON Schema (draft 2020-12)
- Compute
policy_hash=hash_obj(constitution_content) - If signed, verify Ed25519 signature over raw YAML bytes
The authority evaluator determines whether an agent can execute, must escalate, or cannot execute a given tool call.
Policy cascade (order matters):
- Per-tool override (exact match on original tool name)
- Server default policy
- Constitution authority boundaries (with name normalization)
- No match →
cannot_execute(fail closed)
Name normalization for boundary matching:
- NFKC normalize
- Split camelCase at case transitions
- Split on separators (
_-./:\@) - Casefold all tokens
- Join with
.
See Appendix D of the specification for the full algorithm and test vectors.
When a runtime authority engine decides to permit an action with parameter transformation
(AARM R4 MODIFY; Sanna modify_with_constraints), the receipt MUST record three fields
inside authority_decisions[i] per spec Section 2.7:
tool_input_original: the input the agent submitted before transformation (string or object)tool_input_transformed: the input actually passed to the tool after transformationtransformations_applied: array of transformation descriptors{type, target_field, rationale}
Without these fields, an auditor reading the receipt sees only the transformed input and cannot reconstruct the agent's original intent. The schema's A1' conditional rule rejects MODIFY records missing any of the three.
Constitution rule pattern (conceptual):
authority_boundaries:
must_modify: # Phase 2 -- rule engine ships in a future ticket
- condition: "tool == 'search-api' and 'email' in args.query"
transformations:
- type: redact_pii
target_field: query
rationale: PII per AUTH-PII-01Note: the must_modify constitution boundary type and the evaluate_authority dispatch path
that reads it are NOT yet implemented. SAN-369 ships only the recording-infrastructure helpers
below; the rule engine that DECIDES when to MODIFY ships in a follow-up ticket.
Recording a MODIFY decision (Python):
from sanna.enforcement.authority import build_modify_authority_decision
# Custom enforcement code that has decided to transform the agent's input:
record = build_modify_authority_decision(
action="search-api",
original={"query": "find user@example.com records"},
transformed={"query": "find <REDACTED-EMAIL> records"},
transformations=[
{
"type": "redact_pii",
"target_field": "query",
"rationale": "PII per AUTH-PII-01",
},
],
reason="PII redacted from query parameter",
)
# Pass into the authority_decisions list when generating the receipt:
receipt = generate_receipt(
trace_data,
agent_identity={"agent_session_id": session_id},
enforcement_surface="gateway",
invariants_scope="full",
authority_decisions=[record],
)Recording a MODIFY decision (TypeScript):
import { buildModifyAuthorityDecision } from "@sanna-ai/core/dist/authority.js";
import { generateReceipt } from "@sanna-ai/core";
const record = buildModifyAuthorityDecision(
"search-api",
{ query: "find user@example.com records" },
{ query: "find <REDACTED-EMAIL> records" },
[
{
type: "redact_pii",
target_field: "query",
rationale: "PII per AUTH-PII-01",
},
],
{ reason: "PII redacted from query parameter" },
);
const receipt = generateReceipt({
correlation_id: correlationId,
inputs: { query: "..." },
outputs: { response: "..." },
checks: [],
enforcement_surface: "gateway",
invariants_scope: "full",
agent_identity: { agent_session_id: sessionId },
authority_decisions: [record],
});Cross-SDK byte-equal parity: both helpers produce identical records given identical inputs
(key order: action, decision, reason, boundary_type, timestamp, tool_input_original, tool_input_transformed, transformations_applied). A receipt containing a MODIFY record from
either helper passes verification by either SDK (cv=10 21-field fingerprint + Ed25519 signature).
Validation rules enforced at construction time:
transformationsmust be a non-empty list/array- Each transformation must have exactly the keys
{type, target_field, rationale}(extra or missing keys are rejected) originalandtransformedmust be string or object (not null, not array)boundary_typeis set to"can_execute"(the action proceeds with transformation; the schema'sboundary_typeenum does not include a separate MODIFY value)
References:
- Spec Section 2.7: Authority Decisions
- Receipt schema:
AuthorityDecisionRecorddefinition + A1' conditional rule - SAN-369: MODIFY recording infrastructure (sanna-repo
7e0d3ce, sanna-ts60cced0) - SAN-368 (planned):
sanna-verify aarmmechanically verifies MODIFY conformance
Implement these checks in order:
| Step | Check | Exit Code |
|---|---|---|
| 1 | JSON schema validation | 2 |
| 2 | Content hash recomputation | 3 |
| 3 | Fingerprint recomputation | 3 |
| 4 | Status consistency | 4 |
| 5 | Check count consistency | 4 |
| 6 | Receipt signature (if public key available) | 5 |
Return the highest-priority exit code encountered.
- Load the test keypair from
fixtures/keypairs/ - Load each fixture receipt from
fixtures/receipts/ - Recompute
context_hashandoutput_hashfrom inputs/outputs - Recompute the fingerprint from receipt fields
- Compare all computed values against
fixtures/golden-hashes.json - Verify Ed25519 signatures using the test public key
- Optionally: generate a receipt in your language, verify with the Python CLI
If all golden hashes match, your implementation is conformant.
| Pitfall | Symptom | Fix |
|---|---|---|
| HTML escaping in JSON | Hashes don't match (Go, Java) | Disable HTML escaping in JSON encoder |
| Floats in signed fields | Signature verification fails | Sanitize: convert 1.0 → 1, reject 3.14 |
| Missing NFC normalization | Hash mismatch on non-ASCII inputs | Apply NFC at the hashing boundary |
| Sorting checks before hashing | checks_hash mismatch | Hash in insertion order, never sort |
| Omitting null fields in checks hash | checks_hash mismatch | Include "field": null in canonical JSON |
| Using Base64url | Signature rejection | Use standard Base64 with padding |
| Key ID from DER bytes | Key ID mismatch | Hash the raw 32-byte public key, not DER |
| Wrong line endings | hash_text mismatch | Normalize \r\n and \r to \n |
| constitution_approval in fingerprint | Fingerprint mismatch | Strip before computing constitution_hash |
| Language | Package | Status |
|---|---|---|
| Python | sanna |
Released (v0.13.5+) |
| TypeScript | @sanna/core |
Coming soon |