tallwright-cloud
Cloud Proof from Python. The content stays on your machine; a salted address of it is recorded.
This package is the recorder. It commits to the content, keeps the evidence, sends, sends again and reads the receipt. Call it from your agent's code at the points where the agent acts; do not compute a salt or a hash of your own beside it, because a second implementation of the evidence is the one a court would then be asked to trust. What the package promises, and the test that holds each promise, is in its specification.
import os
from tallwright_cloud import Tallwright
client = Tallwright(
"https://ingest.tallwright.com/<your book>", # the ingest address your backoffice shows
os.environ["TALLWRIGHT_KEY"], # the environment's key, shown once; keep it a secret
"./tallwright-evidence", # where the pairs that open your records are kept
)
client.submit({"slot": "2026-11-03T09:00", "service": "haircut"}, action_type="appointment.booked")
Two ways to record
client.submit(content, …)returns once the evidence is safe on your side, and sends in the background: several records to a request, in order, the same bytes again for as long as the door's answer decides nothing. For an agent that must not wait on the network: a voice agent in the middle of a sentence.client.flush()waits for what was submitted;client.close()flushes and ends the sending thread;client.status()says what is queued and whether sending stopped.client.capture(content, …)waits for the door's receipt and raises when it does not come. For an action that must not run unrecorded.
await client.asubmit(…) and await client.acapture(…) do the same from a coroutine without holding
up its event loop. Both ways commit, write and read the door's answer through the same code.
Problems the background sending meets go to the tallwright_cloud logger, and to
Tallwright(…, on_problem=callback), by type and record name and never with content: once per
streak of failures, not once per attempt. A key the door does not hold (401), a closed account (403)
or a redirect stops sending for that client; every record stays pending until a person acts:
client.resend_pending() once the key or the account is right again.
What leaves your machine, and what does not
| Sent to Tallwright | Kept by you, in the evidence |
|---|---|
payload_hash: the address of salt ‖ content |
the content |
record_id |
the 32-byte salt |
action_type, actor_authority, timestamp when you give them |
disclosure.json: both, in the form the public verifier reads |
record_id, action_type, actor_authority and timestamp travel in the clear and are sealed into
every exhibit. Name events, kinds and rules in them, never people: the client refuses a space or an
@ in the two named fields, which catches the easy mistakes and not all of them.
Evidence that outlives the process
The evidence is the only copy of the content and of the salt that opens its address. Lose it and the record can never be opened again, by anybody.
A directory (
LocalEvidence, or a path): on Linux the client refuses, by default, a directory on a file system that ends with its container (overlay,tmpfsand their kind), because a pod or a serverless instance loses it on its next restart. Put it on a volume that outlives the process.A bucket of anything that speaks S3 (
S3Evidence), through an S3 client you make, so this package depends on no SDK of anybody's:import boto3 from tallwright_cloud import S3Evidence, Tallwright evidence = S3Evidence(boto3.client("s3"), "acme-evidence", prefix="tallwright") client = Tallwright(ingest_url, key, evidence)Every object is created only if absent (
If-None-Match: *), so nothing is ever replaced; a botocore older than S3's conditional writes (August 2024) cannot send that, and is refused when the store is made. A bucket that ignores the condition would replace a salt without a word:evidence.check()andtallwright-cloud doctorwrite a probe twice and report it, and report credentials withouts3:ListBucket, without which a record still to be sent cannot be found. Encryption at rest, versioning, object lock and who may read the bucket are yours to set.
Nothing is ever overwritten, in either store. A record pending under one key is never sent with
another: two Environments whose evidence was pointed at one place by mistake each send only their
own. After a key was replaced for the same Environment, client.resend(record_id, adopt=True) sends
a record that was pending under the old one.
Many end customers
One Environment per end customer, each with its own key and its own evidence, all through one sending thread:
from tallwright_cloud import Environments
environments = Environments(ingest_url, key_for=lambda customer: vault.read(customer), evidence=evidence)
environments.client("4711").submit(content, action_type="callback.promised")
key_for(customer_ref) is yours: it returns the key of the Environment made for that customer, and
nothing on this side can tell two customers' keys apart before the door answers. Each customer's
evidence lives beneath its customer_ref, which is therefore a folder name every system takes: up to
100 of the characters [a-z0-9._-], lower case so that no two customers share a folder on Windows or macOS.
account.create refuses any other before it makes anything.
Environments themselves are made by your system with an account key (ta_…, made by an Owner or
Admin on the Settings screen), never in the process that records:
from tallwright_cloud import Account
account = Account("https://app.tallwright.com", os.environ["TALLWRIGHT_ACCOUNT_KEY"])
made = account.create("Bäckerei Müller", customer_ref="4711", deployer_label="acme-voice")
vault.write("4711", made.key.reveal()) # shown once, here and nowhere else
customer_ref is required, so a repeated create makes nothing twice. When an answer was lost on its
way back and the same call tried again, the 409 it meets says so (EnvironmentExists.maybe_ours); a
create you issue again later is a new call and says maybe_ours false. Either way,
account.new_key(error.environment.id) gives the Environment a key you hold. account.find,
account.environments, account.close do what their names say.
What a capture is, and what it is not
A receipt is the door's confirmation that it holds the record: the leaf it sits at, its hash, the time and who set it. It is not a proof, in the door's own words: no witness has seen the record until an anchor covers its leaf, and the proof is the exhibit made after that, checked with the public verifier and the disclosure kept here. Integrity of what was recorded, not completeness of what happened: a record exists for what your code captured, and for nothing else.
When the answer does not come
The pair, the outbox marker and the line are written, each durable, before anything is sent. If the
answer is lost, the same bytes are sent again; a record the door already holds is recognised by its
record_id, never written twice. When no receipt comes at all, capture raises NotConfirmed and the
record stays pending: client.pending() lists it, client.resend(record_id) sends the same line
again, and the next client to send in the background on the same evidence sends it on its own.
Refused means the door read the line and refused what it says, in its own sentence: the same line
will never be accepted. RecordIdConflict (409) means the door holds a different record under this
name; on a resend, whether that is an earlier send of this very line cannot be told from the answer,
and the message says so. An answer without the door's sentence, such as a proxy's error page, is never
taken for a refusal: the record stays pending.
Do not wrap capture in a retry loop of your own. After NotConfirmed, resend: a new capture is a
new record with a new salt, and an answer that was only lost becomes the same event recorded twice.
A receipt can also show that the door lost records it had confirmed: it puts a new record after the
record one of them followed, as a door's log does once it was put back to an earlier state and written
on. Those records are pending again, with the receipt that no longer stands kept beside them, and
LostByTheDoor names them to the logger and to on_problem; resend records each anew from the line
and the pair you kept.
The exhibit
filed = client.save_exhibit(link) # the one-time link the backoffice gives; files <record_id>/exhibit/
The exhibit is filed only if it is of a record this evidence sent and says what its line said: the
same address, action, authority and stated time, and no capture tier claimed. Where the receipt was
kept, it must also name the receipt's leaf, record hash and time. ExhibitNotReady (409) means the door
could not build it yet, most often because no witness has covered the record; the link was not used.
Exhibits(exhibits_url, export_key, evidence).save(record_id) takes it with an export key instead,
kept where the exhibits are wanted and not where the agent runs. Checking the exhibit is the public
verifier's job, evidence-verify, which this package does not install: the Verifier screen of the
backoffice says how to get it. Then, offline, in the record's folder:
evidence-verify exhibit/evidence.json --policy exhibit/policy.json --disclosure disclosure.json.
Its first answer is 3, bundle: NOT VERIFIED with integrity: VERIFIED: everything holds, and the
reference time and the timestamp authority are the reader's to choose, not ours. exhibit/our-claims.json
proposes both; a reader who accepts them writes its as_of and tsa_anchor values into a copy of the
policy, checks again, and the answer is 0, bundle: VALID. A disclosure with one byte of the content
changed answers 1, bundle: INVALID. The lines that do all three are in
Cloud Proof over HTTP.
The command line
tallwright-cloud doctor [--check-key] what stands between this machine and evidence somebody can check
tallwright-cloud pending [--all-keys] records sent and never confirmed or refused
tallwright-cloud resend [ID …] send pending records again, byte for byte
tallwright-cloud gaps numbers missing from a session's sequence
tallwright-cloud export ID DIR copy one record's evidence into a folder, for the verifier
The ingest address, the evidence and the key come from TALLWRIGHT_INGEST, TALLWRIGHT_EVIDENCE
and TALLWRIGHT_KEY. The key is never an argument: an argument is visible to every user of the
machine. Evidence in a bucket is s3://bucket/prefix, read with boto3 when it is installed.
Numbered without a hole
Without a record_id of your own, records are named <session>.<number>, counting up from 000001
for each client in each process (a forked worker draws a session of its own). The name is sealed into
the record, so gaps(record_ids) can list the numbers missing below the highest one a session
reached. A hole is a capture that was numbered and is not in the log. No sequence can show a missing
tail, a session that sent nothing, or an action nothing captured.
Keeping it safe
- The content, the salt and the key are kept out of this package's messages, logs and reprs, and out of the local variables of its frames, so an error tracker that records local variables records none of them from here. Your own code's frames are yours.
- The key travels over https only (plain http to a door on this machine), as a header no redirect carries, under this package's own certificate check. Proxies from the environment apply to other hosts and never to a door on this machine.
- An exhibit link and an export key are credentials: keep them out of logs and traces.
- On POSIX the evidence folders are 0700 and the files 0600. On Windows they take the permissions of the folder they are made in, so make the evidence directory where only the agent's account can read.
For a language without an SDK, the same contract over plain HTTP is Cloud Proof over HTTP; code that follows it does what this package does, and its author answers for doing it exactly as written there.
License
Copyright 2026 David Jöbstl. Licensed under the Apache License, Version 2.0: the package carries the
license text and the NOTICE. It requires tallwright-core, which is under the same license. Installing it
also brings what tallwright-core requires, pydantic 2 and rfc8785 with what those require, each
under its own license.