Metadata-Version: 2.5
Name: bayes-vault
Version: 0.1.0
Summary: Python client for the Bayes Vault REST API.
Project-URL: Repository, https://github.com/awesomenuzzo/bayes_vault
License-Expression: MIT
License-File: LICENSE
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: anyio>=4
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.10
Description-Content-Type: text/markdown

# bayes-vault (Python)

The Python client for the Bayes Vault REST API provides the same two clients as the TypeScript SDK (`@bayes-health/vault`), with synchronous and asynchronous versions built on httpx and pydantic 2:

- `VaultClient`: authenticates with your API key (the tenant key) and must run only on your backend. Every call identifies a user. It also creates agent sessions and connect links, and manages your webhook endpoint.
- `VaultUserClient`: authenticates with the token from a session that your backend created for one user. It can run in your backend, an agent, or a native app. It cannot identify any other user.

All methods, models, and error classes are generated from Vault's OpenAPI document (`docs/openapi.yaml`). The developer platform serves this document at `https://platform.getbayes.com/openapi/vault.json` to a Bayes developer token. The transport, retries, pagination, streaming, polling, and webhook verification are handwritten to match the TypeScript SDK (`docs/developer/sdk.md`), including the same retry policy, cursor handling, stream protocol, and webhook verification.

## Installation

The package is not yet available on PyPI and requires Python 3.10 or later. Install it from the repository:

```sh
pip install "bayes-vault @ git+https://github.com/awesomenuzzo/bayes_vault#subdirectory=clients/python"
```

Alternatively, build and install a wheel:

```sh
cd clients/python
uv build                     # creates dist/bayes_vault-0.1.0-py3-none-any.whl
pip install dist/bayes_vault-0.1.0-py3-none-any.whl
```

The runtime dependencies are httpx, pydantic 2 and anyio.

## Backend quickstart

```python
import os
from bayes_vault import VaultClient

vault = VaultClient(os.environ["BAYES_API_KEY"])      # base_url defaults to https://vault.getbayes.com

reads = vault.records("user_8412")                     # the id of your authenticated user
page = reads.query(vault_type="lab", code_group="hba1c", limit=50)
for record in page.data:
    print(record.record_id, record.handle)

for encounter in reads.iter_query(vault_type="encounter"):   # all pages, pinned to a single snapshot
    save(encounter)

window = reads.text(page.data[0].record_id)
print(vault.get_status("user_8412").model_dump())
```

Use `base_url` only when targeting another Vault origin, such as a local server (`http://localhost:3000`). The value must be an origin without a path because Vault serves its API only from the root of its own origin.

The client strips whitespace around the key or token, including a trailing newline from a secret file. If the credential contains internal whitespace or control characters, the client raises `ValueError` without including the value in the error.

## Agent sessions

Your backend creates a session for each agent conversation and grants only the permissions that the agent needs. Provide an MCP client with the session's `mcp.url` and `mcp.headers` alongside the MCP servers for your other tools. `mcp.headers` is a plain dict containing the `Authorization` header with the token, while `mcp.model_dump()` returns the complete entry as a dict:

```python
session = vault.sessions.create("user_8412", permissions=["read", "connect"], toolset="default")
session.mcp.url; session.mcp.headers; session.token     # the MCP entry, and the token for VaultUserClient
```

For example, with the OpenAI Agents SDK, pass `MCPServerStreamableHttp(params={"url": session.mcp.url, "headers": session.mcp.headers})`. The Claude Agent SDK supplies its MCP servers to the Claude Code process through the command line, where other local users can read the token. Therefore, when using it, use the header `{"Authorization": "Bearer ${BAYES_VAULT_SESSION_TOKEN}"}` and pass the token in `ClaudeAgentOptions(env=...)`.

Available permissions are `read`, `read_full` (includes `read`), `attachments`, `export`, `refresh`, `annotate` (with `read` or `read_full`) and `connect`. By default, sessions receive `read` and `connect`. Use `expires_in` (60 to 31,536,000 seconds) to end a session sooner. A user can have up to 100 live sessions; creating an additional session ends the least recently used one. `vault.sessions.get(id)` returns a session with its current token, `list(user)` returns a user's live sessions, `list_all()` and `iter_sessions()` return the tenant's sessions, and `delete_all()` ends all of them. Store the session's `id`, not its token. At the end of the conversation, call `vault.sessions.delete(session.id)` to end the session immediately, including any connect links it created. Otherwise, the session ends after 30 days without use.

You can also use a session's token with `VaultUserClient`:

```python
from bayes_vault import VaultUserClient

agent = VaultUserClient(session.token)

home = agent.chart.home(format="markdown")              # the patient summary (beta surface chart)
hits = agent.chart.search(pattern="metformin")
labs = agent.records.query(vault_type="lab", limit=20)
status = agent.get_status()
```

Treat clinical text returned in any response as untrusted source evidence, and do not follow instructions contained in it. This package does not wrap MCP.

## Connect links and connections

A connect link opens the hosted page where a user can connect a health system, whose records are then stored in Vault. Send the link only to that user because, until it expires, anyone with the link can connect a health system to the user's chart:

```python
link = vault.connect_links.create("user_8412", return_url="https://app.example.com/connected")   # expires_in: 3600 by default
link = agent.connect_links.create()            # the session needs the connect permission: 60 minutes, or until the session ends
vault.delete_connection("user_8412", "conn_8f2e")    # records already stored stay; delete_source erases them
```

A link created by a session must return to one of your application's origins registered on the Bayes platform.

## Async

`AsyncVaultClient` and `AsyncVaultUserClient` expose the same methods. Await method calls and use `async for` with iterators and streams:

```python
from bayes_vault import AsyncVaultClient

async with AsyncVaultClient(api_key) as vault:
    page = await vault.records("user_8412").query(vault_type="lab")
    async for point in vault.records("user_8412").iter_points(codes=["4548-4"]):
        ...
```

## Methods

Methods use snake_case names and follow the TypeScript SDK's groups:

| Group | API key | Session |
|---|---|---|
| Records layer | `vault.records(user)` | `agent.records` |
| Chart data | `vault.chart(user)` | `agent.chart` |
| Exports for one chart | `vault.exports(user)` | `agent.exports` |
| Tenant exports | `vault.tenant_exports()` | none |
| Chart management | `vault.get_subject(user)`, `vault.get_status(user)`, `vault.list_connections(user)`, `vault.list_sources(user)`, `vault.refresh_records(user, connection_id=...)`, `vault.get_refresh_job(user, job_id)`, `vault.reprocess_subject(user, scope=...)` | `agent.get_status()`, `agent.list_connections()`, `agent.list_sources()`, `agent.refresh_records(connection_id=...)`, `agent.get_refresh_job(job_id)` |
| Data erasure | `vault.delete_subject(user)`, `vault.delete_source(user, source_id)`, `vault.get_job(job_id)`, `vault.wait_for_job(job_id)`, `vault.activate_subject(user)`, `vault.authorize_source(user, source_id)`, `vault.authorize_connection(user, connection_id)` | none |
| Tenant-level operations | `vault.get_settings()`, `vault.update_settings(...)`, `vault.get_usage()`, `vault.list_subjects()`, `vault.get_changes()`, `vault.ack_changes(through_seq=...)`, `vault.get_audit()`, `vault.list_endpoints()` | none |
| Sessions | `vault.sessions`: `create`, `get`, `list`, `list_all`, `iter_sessions`, `delete`, `delete_all` | none |
| Connect links and connections | `vault.connect_links.create(user, ...)`, `vault.delete_connection(user, connection_id)` | `agent.connect_links.create(...)` |
| Webhooks | `vault.webhook`: `get`, `set`, `rotate_secret`, `delete`; `vault.deliveries`: `list`, `iter_deliveries`, `replay` | none |
| Comments from patients | `vault.comments(user)`: `list`, `get`, `history`, `purge` | `agent.comments`: `list`, `get`, `history`, `create`, `edit`, `retract` |
| Patient-reported health records | `vault.patient_records(user)`: `purge` | `agent.patient_records`: `create`, `edit`, `retract` |
| Medical code lookup | none | `agent.codes`: `search`, `lookup` |

The records layer includes `query`, `count`, `get`, `history`, `text`, `resource`, `original`, `references`, `referenced_by`, `attachment`, `imports`, `get_import`, `import_original`, `stream`, `changes`, `series` and `latest`. The docstring for each method documents its required session permissions and beta surface.

Pass path parameters positionally (`reads.text(record_id)`). Pass all other parameters as keyword arguments using their wire names. Python reserved names are the exceptions: `from` becomes `from_`, and a member such as `_vault` becomes `vault_` on a model. Keyword arguments set to `None` are not sent. Methods that accept a record id require the record id (`vrec_…`), not a readable handle. Methods that accept a source id require the source's public `source_id` (`vsp_…`), returned by `list_sources` and every record row. A path parameter equal to `.` or `..` raises `ValueError`. Otherwise, URL parsing would remove the parameter and route the request elsewhere.

Annotations (beta surface `annotations`) provide patient comments, patient-reported records, and code lookup. Your application can use Annotations to work with the patient to add patient-reported records and comments to the chart. Annotations are currently in beta. Contact us to enable them for your tenant. Writes require a session with the `read` and `annotate` permissions. `idempotency_key` is optional; when omitted, the client generates a key. Repeating a write with the same key and body returns the first write's result. Therefore, after a retryable error, the client retries the write with the same key. Use `dry_run=True` to return the data that the write would store without storing it. See `docs/developer/annotations.md` and `docs/developer/code-lookup.md`.

`refresh_records` refreshes a single connection when you provide `connection_id`. If you omit `connection_id`, it refreshes every connection that stores records in Vault. The returned `jobs` contains one entry per connection, with either its `job` or its `error`.

`get_audit` and `iter_audit` list the tenant's read audit in newest-first order. Vault retains each row for six years after the request, including after the chart is erased. Rows for an erased chart retain the erased generation's `public_id` and do not include an `external_user_id`.

`vault.request(method, path, query=..., json=...)` sends an arbitrary request with the client's credential and retry policy. Use it for routes that have no method in this version. `path` must begin with `/vault/`; otherwise, the client raises `ValueError`, ensuring that the credential never leaves the Vault origin. In `query`, a list value repeats the parameter once for each item. For parameters that accept a comma-separated list, join the items yourself.

## Models

Responses are pydantic models from `bayes_vault.models`. Because `/v1` changes only by addition, the models preserve undeclared members in `model_extra`. For the same reason, string enums accept values unknown to this version. Every list page uses the same envelope: `data`, `next_cursor`, `has_more` (true exactly when there is a `next_cursor`) and `count` (the rows in `data`), along with the route's own members. If a response violates the published schema, the client raises `VaultResponseValidationError`, whose `body` contains the parsed JSON. The message and `problems` describe only where and how schema validation failed and never include any value from the body, making them safe to log.

Cursors and snapshot ids (`vt1.…`) are sealed tokens. Return them exactly as received; the client does not decode them.

## Revisions and generations

Every read includes the chart's `revision` and `generation`. When passing back a `revision`—as `revision`, `changed_since_revision`, or a revision used as `since` or `until` on `changes`—provide its associated `generation`. `generation` is optional, and a revision provided without it is read in the chart's current generation. If it is provided and the chart has since been erased and activated again, or restored, Vault returns `410 chart_reset` and includes the current generation in `details["generation"]`. Vault returns the same response for any cursor or snapshot from the previous generation. Treat `generation` as an opaque number and compare it only for equality.

When reading a record's full text in windows, include the first window's `version_id` with every subsequent window. This prevents an import between windows from combining two versions:

```python
parts: list[str] = []
offset: int | None = 0
version_id: str | None = None
while offset is not None:
    window = reads.text(note_id, unit="chars", offset=offset, version_id=version_id)
    parts.append(window.text)
    offset, version_id = window.next_offset, window.version_id
```

## Errors

Every failed request raises `VaultApiError`. Each code in Vault's error catalogue maps to its own subclass in `bayes_vault.errors`, such as `RateLimitedError`, `CursorExpiredError` or `ChartResetError`:

| Attribute | Description |
|---|---|
| `code` | The catalogue code. Branch on this value or the class, never on the message. |
| `status` | The HTTP status, or 0 if no response arrived. |
| `message` | The server-provided message for people. |
| `retryable` | The body's `retryable` value. If absent, it is true for 429 and 5xx. |
| `retry_after` | The number of seconds to wait, taken from the body's `retry_after` or, if absent, the `Retry-After` header. |
| `request_id` | The `X-Request-Id` to provide to support. |
| `details` | The server's `error.details`, returned exactly as sent. |

`details` contains the information required for each code. For `rate_limited`, `details["bucket"]` identifies the bucket, and `details["limit"]` identifies the limit that rejected the request: `credential`, `chart` (the tenant key's limit on one chart), `tenant` (the ceiling shared by every credential), or `url` (one capability URL's downloads, for the `link_downloads` bucket only). If a chart search pattern is rejected as `regex_unsupported`, `details["feature"]` identifies the reason. A `repetition_size` rejection also provides `details["size"]` and `details["reach"]`. For `invalid_request`, `details["reason"]` identifies the cause when Vault has one, such as `page_size` or `invalid_revision`. For `chart_reset`, `details["generation"]` contains the chart's current generation. For a stream error, `details["cursor"]` resumes the stream after the last record you received.

```python
from bayes_vault import VaultApiError
from bayes_vault.errors import ChartResetError, CursorExpiredError

try:
    page = reads.query(cursor=saved_cursor)        # a cursor contains its filters, so send it by itself
except CursorExpiredError:
    page = reads.query(vault_type="lab")          # cursors expire after an hour: start again
except ChartResetError:
    resync_from_scratch()                          # the chart was erased and reactivated, or restored
except VaultApiError as error:
    log.error("vault %s %s %s", error.code, error.status, error.request_id)
    raise
```

If this version does not recognize a code, it raises the base `VaultApiError`. Handle the error using `status` and `retryable`. `ConnectionCutError` (`connection_cut`) is raised by the client and is never sent by Vault: either the connection dropped or a body ended early. Every exception is pickleable, so it can be returned from a worker process (multiprocessing, Celery, Ray).

## Retries

The client retries a request only when repeating it is safe. This includes a `GET` or an operation marked `x-idempotent` in the OpenAPI document, such as `POST …/query`, `POST …/chart/open`, `POST …/chart/search`, `DELETE …/subjects/{user}`, `DELETE …/sources/{sourceId}`, `POST …/activate` or `POST /vault/v1/erasures/ack`. The TypeScript SDK repeats these requests, and `is_idempotent(method, path)` indicates whether a request is one of them. The six annotation writes are marked `x-idempotent` only because repeating a request with the same `idempotency_key` and body returns the result of the first write. Without `idempotency_key`, each request creates a new write, so the client includes a key with every write.

- It retries errors whose `retryable` is true and connections that fail before any response, up to `max_retries` times (default 2).
- When Vault provides `retry_after`, it waits for that duration. Otherwise, it uses exponential backoff with jitter, starting at 0.25 seconds, and caps each wait at 30 seconds.
- If Vault requests a wait longer than 60 seconds, the error is raised directly to you.
- It does not repeat any other request, including `POST …/sessions`, `POST …/connect-links`, `POST /vault/v1/webhook/secret`, `POST …/replay`, `POST …/refresh`, `POST …/reprocess` or `POST …/exports`, even after a `429`, because Vault may already have processed it. If the connection is interrupted during one of these requests, the client raises `ConnectionCutError` with `retryable=False`; check the result before repeating the request.
- It does not repeat `vault.webhook.set`, even though `PUT /vault/v1/webhook` is `x-idempotent`, because the secret is returned only by the call that creates the endpoint.
- A session or connection that no longer exists returns `not_found`. If a retry of `vault.sessions.delete` or `vault.delete_connection` receives that response after an attempt that received no response or a 5xx, the earlier attempt may have deleted it, so the method returns `deleted: True`.

When the server is too busy to compile the pattern in time, a later page of a regex chart search returns `503 search_busy`. Because chart searches are safe to repeat, the client waits for `Retry-After` and then resends the same cursor.

Set `max_retries=0` to manage every retry yourself. `on_response` receives the `ResponseMeta` for each response: the status, the request id, `rate_limit` (`limit`, `remaining` and `reset` from the `RateLimit-*` headers, when all three are present), `deprecation`, `sunset` and `etag`.

`timeout` (seconds, or an `httpx.Timeout`) sets how long each request waits. By default, connections can take 10 seconds, and each read can take 60 seconds. If you pass `timeout`, it also applies when you provide your own `http_client`; otherwise, that client's own timeout is used.

## Pagination

Each paged method has an `iter_` variant that automatically follows `next_cursor`: `iter_query`, `iter_history`, `iter_imports`, `iter_changes`, `iter_points`, `iter_latest`, `iter_references` and `iter_referenced_by` on the records layer; `iter_search` on the chart layer; `iter_exports`, `iter_files` and `iter_parts` on the exports; `iter_comments` and `iter_history` for comments; `iter_sessions` and `iter_deliveries`; and `iter_connections`, `iter_sources`, `iter_subjects`, `iter_changes` (the tenant feed), `iter_audit` and `iter_endpoints` on the clients. Iteration ends when a page has no cursor or includes `has_more: false`.

A cursor contains the request that created it, including its filters, sort, shape and snapshot. Vault ignores any argument provided with a cursor except the route's page size (`limit`, or `max_keys` on `latest`), the tenant feed's `wait`, and chart search's `generation`. Therefore, each subsequent page includes only its cursor and those arguments. When you provide `cursor=` directly, the client sends only the cursor and those arguments, omitting your other arguments:

```python
page = reads.query(vault_type="lab", limit=100)
while page.next_cursor:
    page = reads.query(cursor=page.next_cursor, limit=100)
```

Cursors expire one hour after the page that returned them (`410 cursor_expired`). To pin multiple reads to the same revision, pass a page's `snapshot_id` to another first request.

The tenant feed includes a cursor on every page, allowing you to continue following it. Store the last `tenant_seq` you applied, and acknowledge erasure markers only after applying them and storing their `public_id` to `external_user_id` mapping:

```python
seq = last_processed_seq                         # the last tenant_seq applied and stored (0 to start)
page = vault.get_changes(after_seq=seq, limit=500)
while True:
    for entry in page.data:                      # entry.entry is one of "revision", "erasure" or "source_event"
        apply(entry)
        seq = entry.tenant_seq
    save_processed_seq(seq)
    if page.data:
        vault.ack_changes(through_seq=seq)
    if not page.has_more:
        break                                    # or continue following: vault.get_changes(cursor=page.next_cursor, wait=20)
    page = vault.get_changes(cursor=page.next_cursor, limit=500)
```

## Streaming

`reads.stream(...)` returns a `VaultStream` over `…/resources.ndjson`, containing every matching record in one pinned snapshot. `shape` can be `summary` (the default), `full` or `original`:

```python
with vault.records("user_8412").stream(resource_types=["Observation"], shape="full") as stream:
    for row in stream:                   # dicts; stream.lines() returns each line exactly as sent
        save(row)
end = stream.end                         # the final line: snapshot_id, revision, generation, public_id, records_sent
assert end is not None                   # set after the stream has been read to the end

# Later, retrieve only what has changed since then:
vault.records("user_8412").stream(resource_types=["Observation"], shape="full",
                                  changed_since_revision=end["revision"], generation=end["generation"]).to_file("delta.ndjson")
```

The stream follows continuation lines and yields records only after a checkpoint confirms them. If the connection drops, the stream resumes from the last checkpoint, ensuring that each record is delivered exactly once. Retryable error lines resume the same way, and both scenarios count toward `max_retries`. Every continuation and resume sends only the cursor because it contains the filters, the shape and the snapshot. If the stream cannot resume, it raises `ConnectionCutError` or the error line's class. If the error provides a resume point, `details["cursor"]` continues after the last record you received. To resume later, pass it as `cursor=` to `stream()`; the client sends it alone and ignores every other argument.

Vault ends each batch with a checkpoint. To bound memory usage, the stream holds no more than 10,000 records or about 32 MiB of lines while waiting for a checkpoint. After that limit is reached, it yields the response's records as they arrive, but no exact resume point is available until the next control line: `stream.cursor` is `None`, and a dropped connection raises `ConnectionCutError` without `details["cursor"]` instead of delivering records twice.

`stream.cursor` identifies the position immediately after the last batch delivered in full. It advances only after you receive the batch's final record, never before or partway through a batch. If you stop partway through a batch and later resume from `stream.cursor`, the entire batch is delivered again, so make your writes idempotent (for example, keyed on `record_id`). It stays `None` until a resume point is known; for example, when a stream starts without a cursor, it remains `None` until the first batch has been delivered in full. Leaving the `with` block, calling `close()`, or exiting a `for` loop over the stream early (with `break` or an exception) ends Vault's stream. With `async for`, the stream ends after the event loop cleans up the abandoned loop, so use `async with` to end it immediately. Each stream can be read only once.

## Downloads and conditional reads

- `reads.attachment(record_id, n, range=byte_range(start), if_range=etag)` returns `BinaryContent` containing `content`, `status` (206 for a range), `etag` and the served `range`. If the body ends before its `Content-Length`, it raises `ConnectionCutError`. The `details["received"]` value indicates where to resume.
- `reads.original(record_id)` and `reads.import_original(import_id)` return the stored bytes.
- `vault.exports(user).download(export_id, name, to="records.ndjson.gz")` downloads one file from a completed export to disk. Export files can be as large as 1 GiB, and `file()` holds the entire file in memory, so use `download()` for these files. It writes chunks as they arrive (pass `on_chunk(chunk, offset)` to process chunks yourself). If the body is cut short, it resumes using `Range` and `If-Range`. If the file changed in the meantime, the download starts over, and `on_restart` allows you to discard what you wrote. It returns an `ExportDownload` containing the `etag`, `size`, `resumes`, and `restarts`. `agent.exports` and `vault.tenant_exports()` expose the same method.
- `download_export_link(url, to=...)` (and `adownload_export_link` for async code) downloads a file from a capability URL generated by `links()`. Because the URL is the credential, no key or token is sent. The download resumes and restarts in the same way as `download()`. If every download slot for the key or URL is in use (`429 rate_limited` with `details["bucket"]` set to `link_downloads`), it requests the same bytes again after the `Retry-After`, up to `slot_retries` times (default 60), with a maximum of 5 minutes per request, because a slot can remain in use for that long after its client disconnects. Other retryable refusals are retried up to `retries` times (default 12), waiting no more than 60 seconds per request. Any other refusal is raised as its `VaultApiError`. This behavior matches the TypeScript SDK's `downloadExportLink`.
- `reads.resource(record_id, if_none_match=etag)` returns a `ResourceResult` with `not_modified=True` when your copy is current.
- File bytes are sent only when Bayes has enabled your tenant's `attachments` beta surface. While the surface is off, `attachment` raises `SurfaceNotEnabledError`. Other full reads (`resource`, `original`, `import_original`, `changes` with `include="resource"`, and `shape="full"` or `shape="original"` queries and streams) return each resource without its file bytes and mark every removed element with the data-absent-reason extension (`masked`). In this case, `resource` uses the ETag `W/"<version_id>.files-withheld"`. See `docs/developer/records-api.md`.

## Erasure

`delete_subject` erases a chart and blocks imports until you activate it again. It returns a `job_id`; `wait_for_job` polls `GET /vault/v1/jobs/{job_id}` until the job is `done`, `failed` or `cancelled`:

```python
erasing = vault.delete_subject("user_8412", block_future_imports=True)
job = vault.wait_for_job(erasing.job_id)          # job.phase is first "rows", then "bridge"; job.state ends as "done"
vault.activate_subject("user_8412")               # permits imports again; erased data is never restored
```

Use `connections="keep"` (the default) or `"remove"` to determine what happens to your tenant's connections for the user. `keep` leaves them connected but prevents them from storing data in Vault, while `remove` also deletes them. After activating a chart that was erased with `keep`, call `authorize_connection(user, connection_id)` for each connection that the user agrees to store again. `delete_source(user, source_id)` erases one source. After the user authorizes storage for that source again, `authorize_source(user, source_id)` allows its next import to reactivate it. Both `DELETE` requests can be repeated safely. See `docs/developer/deletion.md`.

## Polling

- `vault.exports(user).wait(export_id)` polls an export until it enters a final state (completed, failed, cancelled, expired or deleted). It honors the `Retry-After` value from each status (2 to 30 seconds) and from a 429 (up to 60 seconds; a 429 requesting a longer delay is raised to you). `on_status` receives every status. `timeout` limits all waiting, including retries: `TimeoutError` is raised if it expires while the export is still running, or immediately when a wait requested by Vault would exceed it.
- `vault.wait_for_refresh_job(user, job_id)` and `agent.wait_for_refresh_job(job_id)` poll a job started by `refresh_records` until it succeeds or fails.
- `vault.wait_for_job(job_id)` polls an erasure or reindex job until it is done, failed or cancelled.

## Webhooks

Vault sends your tenant's events to a single webhook URL and signs them with the Standard Webhooks scheme. Set the URL once. The first call returns the signing secret in `secret`; Vault never displays it again:

```python
hook = vault.webhook.set("https://app.example.com/webhooks/bayes")   # hook.secret only when this call created it
rotated = vault.webhook.rotate_secret()                              # rotated.secret: the old secret stops working at once
```

The client does not retry `set`. If the call fails without a response, or does not return a `secret` when one was expected, Vault may already have created the endpoint. Call `rotate_secret()` to obtain a new secret.

`vault.deliveries.replay(delivery_id)` immediately resends a delivery with the same `webhook-id`. A delivery cannot be replayed if its payload was removed 15 days after creation or redacted after an erasure (`GoneError`). Replays are limited to 60 at once, then 1 per second (`RateLimitedError`). To resend failed deliveries, skip deliveries that cannot be replayed and wait when Vault requests it:

```python
import time
from bayes_vault.errors import RateLimitedError

for delivery in vault.deliveries.iter_deliveries(status="failed"):
    if delivery.payload is None or delivery.redacted:
        continue                                                     # removed after 15 days, or redacted after an erasure
    while True:
        try:
            vault.deliveries.replay(delivery.id)
            break
        except RateLimitedError as error:
            time.sleep(error.retry_after or 1)
```

Verify each delivery against the raw body. For example, with FastAPI:

```python
from bayes_vault import VaultWebhookError, is_vault_event, parse_vault_event, verify_vault_event

@app.post("/webhooks/bayes")
async def bayes_webhook(request: Request) -> Response:
    body = await request.body()
    try:
        event = verify_vault_event(payload=body, headers=request.headers, secret=os.environ["BAYES_WEBHOOK_SECRET"])
    except VaultWebhookError:
        return Response(status_code=400)
    if is_vault_event(event):
        enqueue(parse_vault_event(event))   # VaultChartReadyEvent, VaultSubjectErasedEvent, ...
    return Response(status_code=204)
```

`verify_vault_event` validates `webhook-signature`; any of multiple `v1,` signatures may match, and comparison uses constant time. It also requires `webhook-timestamp` to be within `tolerance_seconds` (default 300) of the current time. It raises `VaultWebhookError` when `tolerance_seconds` is not a finite number greater than or equal to 0, such as `float("nan")` from an unset setting. `secret` is your tenant's webhook signing secret in base64, with or without a `whsec_` prefix. It accepts and rejects exactly the same inputs as the TypeScript `verifyVaultEvent`, produces the same messages, and verifies deliveries signed by Vault's own signer. Deduplicate deliveries by the returned `id`, which remains unchanged across retries and replays.

For Vault events, `parse_vault_event` returns the typed model. Supported event types are `vault.records.ready`, `vault.records.updated`, `vault.chart.ready`, `vault.subject.erasing`, `vault.subject.erased`, `vault.source.erased`, `vault.source.reauthorized`, `vault.export.completed`, `vault.export.failed`, `vault.connection.authorized`, `vault.connection.revoked`, and `vault.connection.reauth_required`. `VAULT_EVENT_KEYS` lists the `data` keys associated with each event type. See `docs/developer/webhooks.md` for details about what each event means.

## Development

```sh
cd clients/python
uv sync                                  # Python 3.12 with the locked development tools
uv run python scripts/generate.py        # regenerate src/bayes_vault/_generated from docs/openapi.yaml
uv run python scripts/generate.py --check
uv run mypy                              # --strict, for the package and the generator
uv run pytest
```

Regenerate after every change to `docs/openapi.yaml`. CI fails if the generated code is stale. Method names are defined by the `METHODS` table in `scripts/generate.py`. If a route is not named in the table, it still receives a method named after its operationId, and the generator reports this; if the table contains an entry for a route that is missing from the document, the generator stops. `tests/test_coverage.py` checks every operation recorded by the client and every route called by its handwritten code against the document's method and path. It also checks that the requests the client retries match the TypeScript SDK's table (`packages/client/src/vault/idempotence.ts`).

To generate `tests/fixtures/webhook_vectors.json`, run `node clients/python/scripts/webhook_vectors.mjs` from the repository root after running `pnpm --filter @bayes-health/vault build`. The script uses Vault's own signer (`apps/api/src/lib/vault/webhooks/sign.ts`), so the vectors include the TypeScript implementation's own outcomes for the data Vault sends, plus one delivery for every event type. CI regenerates the file and fails if it differs. Regenerate and commit the file whenever `verifyVaultEvent`, the signer or the event catalogue changes. To run the smoke tests against another document, with the package generated from that document, run `BAYES_VAULT_SPEC=<document> uv run pytest tests/test_smoke.py`.

The tests use httpx's mock transport and a fixture server generated from the OpenAPI document. Vault ignores all values sent alongside a cursor except the page size, but the server rejects them, allowing the tests to detect a client that sends more than the cursor requires. The tests use synthetic data only.
