Receipt-Driven Architecture
A complete engineering guide to building software that keeps no logs — where every meaningful action produces a cryptographic receipt instead of a line in a file. Lifecycle, anatomy, quorum signatures, offline verification, and patterns for real programs.
Contents
1. The core idea
Most programs prove they did something by writing a line to a log file. The log lives on the operator's server, it grows indefinitely, and proving it hasn't been edited requires trusting the operator. Receipt-driven programming inverts this model:
Core principle
Every meaningful action produces a receipt — a small (~1.5 KB) cryptographic artifact signed by independent parties — instead of a mutable log entry. The receipt travels with the user. Proof no longer lives on your server.
The action's content never leaves the client machine. You compute a hash locally — SHA-256, BLAKE3, SHA-3, or any standard hash function and send only the fingerprint. The network attests the fingerprint — it never sees what you did, only that you did something at a specific moment, and that the hash is exactly what it is.
SHA-256 · BLAKE3 · SHA-3 · your choice
only the hash travels
~1.5 KB, keep it
Once issued, the receipt is yours. It can be verified offline by anyone — without an account, without calling our servers — using the open-source verifier and the published operator keys on GitHub. Even if TimeLayer shuts down tomorrow, receipts issued today stay valid forever.
2. Receipt lifecycle
The full round-trip from program action to verifiable proof, step by step:
Step 1 — Hash the action locally
Decide what constitutes "the action" (see §6.1) and compute its hash fingerprint. Any standard hash function is accepted (SHA-256 is used in examples; BLAKE3, SHA-3, and others work equally well). The content never leaves your process.
# The content is hashed locally — it never travels to the API CONTENT="invoice #4471 approved · amount 1200 EUR · 2026-01-15T09:00:00Z" ACTION_HEX=$(printf '%s' "$CONTENT" | sha256sum | cut -c1-64) # ACTION_HEX is a 64-char hex string — this is all the API will ever see
What to hash · Which algorithm
Hash a canonical, deterministic string that uniquely identifies the action: what happened + who did it + when + any relevant identifiers. Include enough fields so two different actions always produce different hashes. Timestamp resolution of 1 second is usually sufficient.
TimeLayer accepts any hex fingerprint — SHA-256, BLAKE3, SHA-3, Keccak, or any standard hash. SHA-256 is used in examples because it's universally available; the choice of hash is entirely yours.
Step 2 — POST the hash to /v1/notarize
curl -s -X POST https://api.timelayer-os.com/v1/notarize \ -H "Authorization: Bearer $YOUR_API_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"action_hex\":\"$ACTION_HEX\"}"
Step 3 — Receive the receipt
The API returns a JSON object. The most important field is cert_hex — this is the receipt.
Store it alongside or in place of a log entry.
{
"cert_hex": "a3f8…", ← the receipt, keep this
"bundle_hex": "…", ← full bundle (cert + supporting proof)
"notarized_at": "2026-01-15T09:00:01Z"
}
Step 4 — Store and hand off the receipt
In a log-driven program you'd append(log_file, line). In a receipt-driven program
you instead:
- Store
cert_hexin your database next to the action record, or - Hand the receipt file directly to the user — they now hold the proof, not you, or
- Both: keep a copy for your own audit trail, give one to the user
Step 5 — Verify at any point, offline
# Anyone can verify — no account, no network call to TimeLayer timelayer-verifier verify cert.tlcert bundle.tlbundle # → VALID FINAL
3. Receipt anatomy
A receipt (cert_hex decoded) is a self-contained document. It carries everything
needed to verify it — no lookup required at verification time.
{
/* what was notarized */
"action_hex": "…64-char hash…", ← SHA-256 · BLAKE3 · SHA-3 · any standard hash
"notarized_at": "2026-01-15T09:00:01Z",
/* who signed it — each entry is one independent operator */
"signatures": [
{
"operator": "operator-1",
"sig_hex": "…Ed25519 signature over the cert body…"
},
{
"operator": "operator-2",
"sig_hex": "…"
}
/* quorum of operators required */
],
/* network metadata */
"network_version": 2,
"quorum": "satisfied"
}
Key fields explained
- action_hex — the hash fingerprint you sent (SHA-256 in examples; any standard hash is accepted). The receipt binds this exact value, no other.
- notarized_at — when the network attested your action.
- signatures — Ed25519 signatures from independent operators. Each signature covers the full cert body (action_hex + notarized_at + network metadata). A change to any field breaks all signatures.
Size
A receipt is about 1.5 KB (binary cert+bundle). This is fixed regardless of what you notarized — whether you hashed a single byte or a 10 GB file, the receipt is the same size.
| Term | What it is | Size |
|---|---|---|
Certificate (cert.tlcert) | minimal signed proof | ~0.4 KB |
Bundle (bundle.tlbundle) | data for offline verification | ~1.1 KB |
| Full verification package | certificate + bundle | ~1.5 KB |
4. Quorum signatures
A receipt signed by a single key proves nothing about independence: the key holder could sign anything. TimeLayer's trust model is an independent quorum attestation.
The network has independent operators, each holding their own Ed25519 signing key. A receipt requires signatures from a quorum for it to be valid. This has several important consequences:
- No single point of fabrication. Even TimeLayer itself — as just one operator — cannot issue a valid receipt alone. A quorum of independent operators must agree the action happened.
- No single point of failure. If one operator is offline, the remaining operators still form a quorum and receipts continue to be issued.
- Tamper-evidence is shared across operators. An attacker who compromises one operator cannot retroactively alter receipts — the other operators' signatures would fail verification.
Single-signer notary
One key signs. Forge the key → forge any receipt. Operator is the single trust anchor. Receipts are only as trustworthy as that one party.
Quorum (TimeLayer)
Two independent keys must agree. Compromising one party doesn't break receipts. No single operator — including us — can fabricate a receipt.
Published operator keys
Every operator's public key is published openly (GitHub). The verifier fetches the published set once and can then operate fully offline — it just checks Ed25519 signatures against the known keys. You can cross-check the published keys yourself at any time; no trust in the verifier binary is required to know what keys are active.
5. Offline verification algorithm
Verification is deliberately designed to require zero network calls and zero trust in any running service. Here is what the verifier does, step by step:
- Decode cert_hex — parse the JSON certificate from the hex encoding.
- Use the embedded operator keys — the verifier ships with the canonical set of operator keys embedded (no network needed). Each operator maps to an Ed25519 public key; the same keys are also published on GitHub for cross-check.
- Reconstruct the signed message — build the same canonical byte string from
action_hex + notarized_at + network_version + quorum. The exact serialization is deterministic and documented in the open-source verifier. - Verify each signature — for each entry in
signatures, run Ed25519 verify:verify(public_key[operator], signed_message, sig_hex). - Check quorum — count valid signatures. If a quorum of known operators signed, the receipt is valid.
- Output —
VALID FINALorUNVERIFIABLEwith the reason.
What "valid" means
A VALID result means: this exact action_hex was attested at notarized_at
by a quorum of independent network operators, and the certificate has not been modified
since it was issued. It does not mean TimeLayer knows what the action was —
it attested only the hash.
What verification doesn't cover
Verification confirms the receipt is internally consistent and genuinely signed by the network.
It does not prove the hash in action_hex corresponds to any specific real-world action —
that link is yours to maintain (store the original action data + its receipt together).
6. Design patterns
6.1 Defining an action boundary
The hardest design decision in receipt-driven programming is often "what exactly is the action?" The rule of thumb: one action = one commitment you want to be able to prove independently.
- Too coarse — "today's batch of 10 000 file moves" hashed as one blob. You can prove the batch happened, but not any individual move within it.
- Too fine — every internal function call or state transition. You'll notarize thousands of trivial events and the receipts become noise.
- About right — a discrete, meaningful event: "invoice #4471 approved", "file x moved from A to B", "agent decided to send email to user Y".
A practical test: could a third party meaningfully dispute or verify this action on its own? If yes, it's worth a receipt.
6.2 Fail-closed integration
The most important pattern in receipt-driven programs. In log-driven code, a failed write to the log is usually silently swallowed — the action proceeds, the proof doesn't. In receipt-driven code, make notarization a gate:
# Pseudo-code — fail-closed pattern # 1. Prepare the action (validate, build state changes) action_data = prepare_invoice_approval(invoice_id, amount, approver) action_hash = sha256(canonical(action_data)) # 2. Notarize BEFORE committing resp = notarize(action_hash) # raises if network unreachable # 3. Commit only if we have a receipt db.commit(action_data, receipt=resp.cert_hex) return resp
If notarize() raises — network down, quota exceeded, timeout — the action
is not committed. Your system stays provable: every committed action has a receipt; no committed
action is ever unwitnessed.
Idempotency and retries
If notarization succeeds but your commit fails, you hold a receipt for an action that didn't happen. Handle this with a transaction: either both commit or neither does. The receipt for the uncommitted action is harmless — it attests a hash that isn't in your database.
6.3 Receipt store design
Where to keep receipts depends on who needs to verify them:
- Database column — simplest. Add a
receipt_cert TEXTcolumn to your actions table. Verifiable by any party with DB access. - Filesystem (one file per receipt) — good for file processors. Name the file
after the action hash:
{action_hex[:16]}.cert. Portable, no DB required. - Hand to user on completion — best for user-facing actions. The receipt is the "your order is confirmed" attachment. The user holds the proof; your server could burn down and their receipt still works.
- Embedded in artifact — for documents: append the receipt to the PDF metadata
or sidecar
.certfile. Proof travels with the document for its lifetime.
6.4 Idempotency
The same hash value always produces a consistent, verifiable receipt — but the network will
issue a new receipt for each notarization call, with a different notarized_at timestamp.
If you want exactly one receipt per action, enforce that at your application layer: check whether
a receipt for this action already exists before calling the API.
7. The two-program demo
The best way to see the difference is two programs that do the same job — watch a folder, hash each incoming file, move it to an output folder — built in two different architectural styles.
log-driven/
Every file move appends a line to processor.log.
After processing 250 files: ~40 KB log, growing.
Proof of any one move: scroll the log and trust no one edited it.
receipt-driven/
Every file move calls notarize(sha256(move)).
After processing 250 files: 250 receipt pairs (cert.tlcert + bundle.tlbundle), ~1.5 KB each.
No log. Proof of any one move: timelayer-verifier verify cert.tlcert bundle.tlbundle → VALID FINAL, offline.
// receipt-driven/main.rs (simplified) fn process_file(path: &Path) -> Result<Receipt> { // 1. compute the canonical action string let action = format!( "move·{}·{}·{}", path.file_name(), sha256_file(path), utc_now() ); // 2. hash locally — content stays here let h = sha256(action.as_bytes()); // 3. notarize the hash — only h travels over the network let resp = client.notarize(&h)?; // fail-closed: ? propagates error // 4. write the receipt — two files: notarial cert + gate bundle let dir = output_dir.join(&h[..16]); write(dir.join("cert.tlcert"), hex::decode(&resp.cert_hex)?)?; write(dir.join("bundle.tlbundle"), hex::decode(&resp.bundle_hex)?)?; // 5. only now move the file fs::rename(path, output_dir.join(path.file_name()))?; Ok(receipt) }
Key observation: the function returns Result — if notarization fails, the
? propagates the error and the file is not moved. The receipt precedes
the action at the commit point. This is fail-closed in the file system sense.
After running the demo, the receipt-driven/ output directory contains 250
receipt pairs. Pick any one:
timelayer-verifier verify output/a3f8b2c1/cert.tlcert output/a3f8b2c1/bundle.tlbundle VALID FINAL
8. Migrating from log-driven
Migration doesn't need to be all-or-nothing. A practical path:
- Identify action boundaries — audit your existing log format. Each meaningful log event (not debug noise) is a candidate action. You probably have fewer than you think.
- Add notarization alongside logging — for each identified event, add a
notarize(hash(event))call immediately after the existing log write. (SHA-256 is fine; BLAKE3 or SHA-3 work equally well.) Storecert_hexin a new DB column. Run both in parallel for a sprint. - Verify a sample — spot-check 20–30 receipts with
timelayer-verifier. Confirm your canonical string is deterministic (same inputs → same hash → same provable receipt). - Remove log writes for migrated events — once you trust the receipt store,
drop the corresponding
logger.info(…)calls. The receipt is now the record. - Flip to fail-closed — move notarization before the DB commit so a notarization failure prevents the action from committing. This is the point of no return.
- Keep debug/trace logging — receipt-driven applies to meaningful business events, not to every function call. Debug logs are noise; they belong in ephemeral observability tools, not in your receipts. Keep them as-is.
Don't notarize everything
Notarizing HTTP health-checks, internal heartbeats, or debug traces wastes quota and creates noise. A receipt is valuable because it's deliberate — one per meaningful commitment, not one per function call.
9. Engineer FAQ
What if I need to prove the receipt corresponds to a specific real-world event?
Store the canonical action string that produced the hash alongside the receipt. Given the string,
anyone can recompute hash(string) with the same algorithm and confirm it matches action_hex
in the receipt. The receipt then proves: this exact string was attested at this exact time by
2 independent operators. The link between string and real-world event is your responsibility to document.
Does the action hash need to be unique across all users?
No — two different users can notarize the same hash (e.g. the same file hash). Each gets their own receipt with their own timestamp. Uniqueness matters within your own action log: two different actions should produce different hashes, so include user ID and timestamp in your canonical string.
What happens if the API is down when my program tries to notarize?
In fail-closed mode, the action doesn't commit and you retry later. In practice, you should build a small local queue: buffer pending notarizations in a local DB table, drain it asynchronously. Actions commit after the receipt is received from the queue drain. This decouples your hot path from network latency while keeping the fail-closed guarantee.
Can I batch multiple actions into one receipt?
Yes — hash a Merkle tree or a canonical concatenation of all action hashes, notarize the root. The root receipt proves the entire batch was attested at once. The trade-off: you can only prove the batch, not individual actions within it. Use batching for high-volume pipelines where per-action receipts would be wasteful.
How do I handle receipt verification in CI/CD?
The timelayer-verifier binary is a single static executable with no runtime dependencies.
Drop it into your CI image. Add a verification step after your integration tests:
for each output artifact, verify its sidecar receipt. A non-zero exit code fails the build.
This is "receipts as a test oracle" — your CI becomes a proof checker, not just a test runner.
Is this compatible with existing audit logging frameworks?
Yes — receipts are additive. Keep your existing audit log for compliance/debug purposes; add receipt notarization for the subset of events you want to make externally verifiable. The two systems coexist; receipt-driven is not a replacement for all observability.
TimeLayer