Writing a framework adapter for Cloud Proof
A contract for every package that connects an agent framework to Tallwright Cloud Proof
(tallwright-<framework>), written before the first one is built (E11, 2026-10-08). It says what an
adapter does at each event of a framework, and it is held to the one rule underneath all of it:
An adapter decides when to call Tallwright.capture and what content to hand it. Nothing else.
The commitment, the line, the evidence directory, sending, resending, the receipt and the exhibit
belong to tallwright-cloud (packages/tallwright-cloud) and the public verifier. An adapter that
redoes any of them makes a second implementation of the evidence, and that second implementation
is what a court would be asked to trust.
1. What an adapter is given, and what it exposes
from tallwright_cloud import Environments, Tallwright
client = Tallwright(ingest_url, archive_key, evidence) # built by the integrator
adapter = FrameworkAdapter(client, fail_closed=False) # the adapter's own class
environments = Environments(ingest_url, key_for, evidence) # a vendor with many customers
adapter = FrameworkAdapter(client_for=lambda session: environments.client(session.customer_ref))
- It is handed a
Tallwrightclient, or a function that gives one per session, and never builds one. The key and the evidence are the integrator's: an adapter that reads a key from the environment or chooses where evidence lives has made a decision about a credential and about where salts live, and both belong to the person who runs the agent. A vendor whose one agent process serves many end customers hands the adapterclient_for: the Environment of the customer a session is for, which only the vendor's own code knows. submitby default,capturewhen fail-closed. An event is handed toclient.submit, which returns once the evidence is safe and sends in the background, so the agent never waits on the network. Withfail_closed=Truean action is handed toclient.capturebefore the tool runs, and a capture that was not confirmed stops the action.- One client per process. The client names its records
<session>.<number>and mints the numbers under a lock, so the hooks of one process may share it. Whether a framework's concurrent hooks do so safely is shown by the adapter's own test (section 7), not assumed. fail_closed, defaultFalse, with the meaning the self-hosted LangGraph middleware gives it. Best effort keeps the agent serving when a capture fails, and the failure is never silent (section 5). Fail-closed raises before the action runs, and a regulated deployment sets it.pending()returnsclient.pending(), andflush()callsclient.flush(), for the end of a session (a call hung up, a run finished). Those two are all an adapter adds to the client's surface; sending again what is pending is the client's own work (client.resend_pending()).
It never holds an export key. Reading exhibits is a separate object (Exhibits) in a separate
process, and the process that records needs no key that reads.
2. Events, and what each one becomes
Two kinds of record exist, and the door derives the kind from one field: a line with action_type
is ACTION_TRIGGERED, a line without one is CONTEXT_RECEIVED (docs/exhibit-format.md §3).
| Framework event | Record | action_type |
actor_authority |
Content handed to capture (stays on the machine) |
|---|---|---|---|---|
| input: a message, prompt, retrieved document or tool list reaches the agent | CONTEXT_RECEIVED |
none | the rule it arrived under, if the framework names one | an allowlist projection of what the agent received |
| intent | no record | none | none | none. No hook of a framework sees it, the line has no field for it, and an adapter does not invent one |
| action: the agent calls a tool, captured before the tool runs | ACTION_TRIGGERED |
the kind of action (refund.approve), never a sentence |
the role or rule it runs under (role:payments-agent), if known |
the tool's name and its arguments |
| result: what the tool returned | CONTEXT_RECEIVED |
none | none | the value returned, as the agent received it |
| approval: a person or a rule allowed or refused an action, and the agent is told | CONTEXT_RECEIVED |
none | the role or rule that decided (role:payments-supervisor), never a person's name |
the request and the decision as the framework holds them |
| error: a tool or the model failed, and the agent is told | CONTEXT_RECEIVED |
none | none | the error as the agent received it: its type and message, never a traceback with local variables |
An approval is not an action of the agent's, so it is not ACTION_TRIGGERED. The action it allowed
is, in a record of its own, and that record names the same authority in actor_authority.
A capture that failed is not an error event. It is a hole, and section 5 says what happens to it.
3. The fields an adapter may set, and nothing more
| Field | When | Rule |
|---|---|---|
action_type |
action events only | a kind or the name of a rule: 1 to 128 of A-Z a-z 0-9 . _ : / + -, starting with a letter or a digit, no space, no @. It travels in the clear and is sealed into every exhibit |
actor_authority |
when the framework names a role or a rule | the same characters. A role or a rule, never a person |
timestamp |
only when the framework gives the moment of the event | RFC 3339 with its offset. Left out, the door stamps the moment it first takes the record |
record_id |
almost never | Let the client name records: its names carry a sequence, and tallwright_cloud.gaps shows a hole in it. A name of the adapter's own carries none. Only a framework id that is stable and unique per event (a tool-call id) may be used, and then a second line under the same name is refused (RecordIdConflict) |
These are the parameters of capture. There is no other field: the door refuses a line that carries
a name it does not record, rather than seal the record without it. An adapter that wants to say more
says it in the content, which stays on the machine.
4. What stays on the machine, and how it is committed
- The content and its salt stay on the machine.
capturegives the content its one byte form, draws 32 random bytes, and sends only the address ofsalt ‖ content. The pair that opens the address is written to the evidence directory, in the form the public verifier reads. The door never sees content or salt, and neither does any process of ours. - The commitment is
tallwright_cloud.commit.Commitment.of, reached throughcapture. Bytes are taken as they are, text as UTF-8, and a JSON object or array as its RFC 8785 form (tallwright_core.canonical.canonicalize), so the same object commits to the same bytes whoever builds it. An adapter does not hash, salt or canonicalize anything for the line, and does not write to the evidence directory. - The projection is the adapter's one real decision. It hands
capturea dict of the fields it names, an allowlist, and drops what it does not name: provider reasoning blocks, credentials in headers, a framework's internal state. A field nobody chose is a field nobody can defend. Content that is not bytes, text or JSON is refused bycapture, and the refusal names the type, never the value.
5. When something fails
NotConfirmed: resend, never capture again. The line is on disk and listed bypending(). A secondcaptureof the same event draws a new salt and records it twice, and nothing afterwards can tell two events from one recorded twice. The client sends the same bytes again on its own (submit) or onclient.resend_pending(), and a record the door already holds is a confirmation too.RecordIdConflict,Unauthorized,AccountClosed,Refused: surface them. Nothing of that capture was recorded, and a retry records nothing either.- Best effort is never silent. The failure is logged by type and record name, never by content,
and the record stays in
pending(). A capture the adapter skipped on purpose is still a hole in the sequence, andgapsshows it. - Fail-closed raises before the action runs. An action is captured before the tool runs, so a refused capture stops the action, not just its record.
6. What an adapter never does
- It invents no verifier or proof semantics. It adds no check, no verdict and no field to an exhibit, and it says nothing a verifier does not say. The public verifier decides what an exhibit proves.
- It calls a
Capturea receipt: the door's confirmation that it holds a record committing to this address at this leaf. The proof is the exhibit made after a witness has seen that leaf in an anchor, checked with the disclosure kept here. - It does not describe what it captures as all of what the agent did. A framework call the adapter has no hook for, or a hook installed where another layer can still rewrite the call, is outside the record, and the adapter's README names those places for its framework.
- It does not claim to know whether what the agent was told was true. It commits to what was received, not to whether it was right.
7. What every adapter's tests show
- Each event in section 2 becomes the record named there, against a real door: the line's
action_typeandactor_authorityare what the table says, and the content is not on the wire. - An intent-shaped hook, if the framework has one (a plan, a "thought" message), produces no
record, and reasoning blocks are absent from the content handed to
capture. - A lost answer, sent again by the client, gives one record, not two.
- Best effort: a door that refuses leaves the agent running, the record in
pending(), and a log line without content. Fail-closed: the same door stops the action before the tool runs. - Concurrent hooks in one process give distinct records and no hole in the sequence.
- The exhibit of a captured record checks with the public verifier and the kept disclosure, and checks no more after one byte of the kept content is changed.