Metadata-Version: 2.4
Name: allela
Version: 0.1.4
Summary: Tamper-evident attestation for AI decisions, timestamped by DigiCert (RFC 3161)
Author: Sid
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/sid-allela
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=41.0
Requires-Dist: asn1crypto>=1.5
Requires-Dist: rfc8785>=0.1.2
Provides-Extra: api
Requires-Dist: fastapi[standard]>=0.110; extra == "api"
Dynamic: license-file

# Allela SDK

Tamper-evident attestation for AI decisions, timestamped with an RFC 3161
token from DigiCert's timestamp authority.

## Quickstart

Create a project folder and a virtual environment, then install Allela.

macOS / Linux:

```bash
mkdir allela-quickstart && cd allela-quickstart
python3 -m venv .venv
source .venv/bin/activate
pip install allela
```

Windows (PowerShell):

```powershell
mkdir allela-quickstart; cd allela-quickstart
py -m venv .venv
.venv\Scripts\Activate.ps1
pip install allela
```

> **Windows note:** if `Activate.ps1` is blocked ("running scripts is
> disabled on this system"), run this once, then activate again:
>
> ```powershell
> Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
> ```

Save this as `quickstart.py`:

```python
import allela

allela.configure(
    tenant_id="quickstart",
    model_provider="anthropic",
    model_id="claude-sonnet-4-6",
    model_version="20250929",
)

prompt = "Applicant: income $60,000, requesting $10,000 loan."
answer = "APPROVED: Low risk profile."

# 1. Sign the record and get an RFC 3161 timestamp from DigiCert.
event_id = allela.attest(prompt, answer)

# 2. Look up what was stored.
event = allela.get_event(event_id)
print("event:    ", event_id)
print("status:   ", event["status"])  # "success": see below

# 3. Verify the record against the original input and output.
result = allela.reconcile(event_id, raw_input=prompt, raw_output=answer)
print("verified: ", result["path"])   # FULL_INTEGRITY_VERIFICATION
print("timestamp:", result["timestamp_imprint_matches"])  # True
```

Run it:

```bash
python quickstart.py
```

Expected output (your event ID will differ):

```text
event:     evt_...
status:    success
verified:  FULL_INTEGRITY_VERIFICATION
timestamp: True
```

`success` means the record is signed and DigiCert returned a timestamp token that covers it. DigiCert's own signature on that token is not yet verified. `signed_no_tsa` means the record is signed and stored but not timestamped. `tsa_response_mismatch` means DigiCert returned a token that doesn't match the record we sent; the record is signed but not timestamped.

The first run creates three files in the current folder:

- `allela_private.key`: your Ed25519 signing key. Keep it secret and
  never commit it.
- `allela_public.key`: the matching public key used to verify records.
- `allela_ledger.db`: the local, tamper-evident record ledger. Each decision
  record is signed and hashed. UPDATE and DELETE on its `decision_events` table are
  blocked by database triggers, which anyone with access to the file can
  remove; its `intent_gaps` table has no such triggers.

Back up `allela_private.key`: records can only be verified with the key that
signed them. If the key file is missing while `allela_ledger.db` exists,
`configure()` raises `SigningKeyMissingError` instead of creating a new key.
If the key doesn't verify the ledger's newest record (a different key, or an
altered record), `configure()` raises `LedgerKeyCheckError` and changes nothing.
`get_event`, `list_events` and `reconcile` raise `LedgerNotFoundError` if the
ledger file doesn't exist, rather than creating an empty one.

Add `*.key` and `*.db` to your `.gitignore`. Timestamping needs outbound
HTTP access (port 80) to `timestamp.digicert.com`. The request currently uses unencrypted HTTP. Without it, records are still
signed and stored, with status `signed_no_tsa`. If the timestamp server
returns a token that doesn't cover the hash and nonce Allela sent, the token
is discarded and the signed record is stored with status
`tsa_response_mismatch`.

## Using it in your app

Wrap your existing AI call with `@allela.trace` to attest each call to the function you wrap (see "What is not captured" for when a record can't be written):

```python
import allela

allela.configure(
    tenant_id="acme_corp",
    model_provider="anthropic",
    model_id="claude-sonnet-4-6",
    model_version="20250929",
)

@allela.trace(system_prompt="You are a credit risk assistant.")
def make_credit_decision(applicant_profile: str) -> str:
    return call_your_llm(applicant_profile)  # your existing AI call, unchanged

result = make_credit_decision("Applicant: income $60,000, requesting $10,000 loan.")
# `result` is returned immediately — attestation happens on a background
# thread and does not add latency to your function call.

# Before your process exits, flush any in-flight attestations:
allela.shutdown()
```

## Verifying a decision later

Allela never stores your raw inputs and outputs, only hashes of them. **Keeping the
original inputs and outputs is up to you.** To verify a decision later, you
hand them back to `reconcile()`, which checks them against the signed record.

Save this as `verify_example.py`:

```python
import allela

allela.configure(
    tenant_id="acme_corp",
    model_provider="anthropic",
    model_id="claude-sonnet-4-6",
    model_version="20250929",
)

@allela.trace(system_prompt="You are a credit risk assistant.")
def make_credit_decision(applicant_profile: str) -> str:
    return "APPROVED: Low risk profile."  # stand-in for your model call

applicant = "Applicant: income $60,000, requesting $10,000 loan."
decision = make_credit_decision(applicant)
# Store `applicant` and `decision` in your own records; Allela keeps only hashes.

# @trace writes its record in the background. shutdown() waits for that write;
# it does not stop Allela, and @trace keeps working afterwards.
allela.shutdown()

# @trace doesn't return the event ID, so look it up: newest first, per tenant.
event_id = allela.list_events("acme_corp", limit=1)[0]["event_id"]

result = allela.reconcile(
    event_id,
    raw_input=allela.trace_input(applicant),   # same arguments as the original call
    raw_output=allela.trace_output(decision),
)
print(result["path"])  # FULL_INTEGRITY_VERIFICATION
```

**Finding the event ID.** `list_events(tenant_id, limit=20, offset=0)` returns
`event_id`, `timestamp_utc` and `status`, newest first. Taking the newest
event only works if no other traced call ran in between; otherwise match on
`timestamp_utc`. If you need the ID at the moment of the decision, call
`allela.attest(input_text, output_text)` directly instead of using
`@trace`: it returns the event ID.

**Rebuilding the input exactly.** `@trace` records the call's arguments in a
fixed format, and `trace_input()` rebuilds it. It only matches if you pass
the same values in the same way:

- Pass plain values (strings, numbers, lists, dicts), or give your objects a
  stable `__str__`. An object with Python's default `str()` includes its
  memory address, so it never matches.
- Pass arguments the same way as the original call: if it was
  `make_credit_decision(applicant_profile=x)`, use
  `trace_input(applicant_profile=x)`, not `trace_input(x)`. If any keyword
  value can't be turned into JSON (a `Decimal`, for example), keyword order
  matters too.
- A dict passed positionally is recorded with `str()`, so its key order
  matters.

A mismatch from any of these returns `TAMPER_DETECTED`, the same result as
real tampering, so get the arguments right before treating it as an alert.

`result["path"]` is one of:

- `FULL_INTEGRITY_VERIFICATION`: the stored record is intact and your data matches it.
- `TAMPER_DETECTED`: the stored record is intact, but your data doesn't match it (`mismatched_fields` says which).
- `DATA_UNAVAILABLE_AT_SOURCE`: the stored record is intact; no data was supplied to compare.
- `LEDGER_RECORD_INVALID`: the stored record failed its signature or root-hash check (`failed_checks` says which), so it can't be trusted and nothing was compared.
- `NOT_FOUND`: no event with that ID.

`result["timestamp_imprint_matches"]` only confirms that the stored
timestamp token covers this record's hash. It does not yet verify the
timestamp authority's signature or certificate chain.

## What gets stored

Allela stores cryptographic hashes of your input, output and system prompt, plus metadata (model ID and version, timestamps, tenant ID) and any `execution_params` you pass. Your raw input and output text is never stored. `execution_params` is stored in readable form in the ledger file and is part of the signed record, so don't put personal data or secrets in it. See the Decision Event Schema (`v1/decision-event.json`) for the exact shape.

## What is not captured

If an attestation fails, the function still returns its result, a failure is logged (the error type and the event ID or function name only), and no ledger record exists. If the ledger file is locked by another process, a background write waits about 7 seconds and is then dropped, with an error logged. If a timestamp request hangs, it holds up the attestations queued behind it for about 20 seconds per network step and sometimes longer, and `shutdown()` may return `False` with records still pending. The SDK does not retry failed timestamp requests or failed writes. Don't open the ledger with other tools, or copy the file, while your application is running.

## Important: call `allela.shutdown()` before exiting

Because attestation runs on a background worker so it never blocks your
application, an attestation that's still in flight when your process
exits abruptly could be lost. Call `allela.shutdown()` (which flushes
the queue) during your application's graceful shutdown sequence.

`shutdown()` only waits for pending records; it doesn't stop Allela, so
it is also safe to call mid-run, as in the verification example above.

`shutdown()` waits at most `timeout` seconds (default 30; pass `None` to
wait indefinitely) and returns `True` if every attestation was written,
or `False` if the timeout expired with some still pending — in which
case a warning is logged with the count.

## License

Apache License 2.0 — see `LICENSE`.
