Skip to content

Single-use human approval tokens

openmed.agent.approvals provides a local, fail-closed approval gate for high-impact actions. A token is signed over exactly seven claims:

  • the SHA-256 digest of the action or reviewed side-effect preview;
  • a canonical reviewer policy role;
  • an exclusive Unix expiry timestamp;
  • a random 128-bit nonce;
  • a non-secret signing-key identifier;
  • a Unix issuance timestamp; and
  • the token schema version.

Changing any signed claim invalidates the signature. The verifier atomically claims the nonce before it compares the expected action and role. A successful verification, changed action, or wrong role therefore makes that token unusable. Expired, malformed, or incorrectly signed tokens fail without dispatching the action.

Issue and consume a token

The application must authenticate the human reviewer and enforce its own role policy before calling ApprovalTokenSigner.issue(). This module binds the role that the application supplies; it does not authenticate a person, make a clinical judgment, or decide which actions require approval.

from openmed.agent.approvals import (
    ApprovalTokenSigner,
    ApprovalTokenVerifier,
    InMemoryApprovalNonceStore,
    dispatch_with_approval_token,
)

key = b"replace-with-32-or-more-local-key-bytes"
# Synthetic injected time for a fully offline, reproducible example.
now = 1_999_999_900
action_digest = "sha256:" + "a" * 64
reviewer_role = "role:org.example/clinical-reviewer@1.0.0"

token = ApprovalTokenSigner(key, clock=lambda: now).issue(
    action_digest=action_digest,
    reviewer_role=reviewer_role,
    expires_at=2_000_000_000,
)

verifier = ApprovalTokenVerifier(key, InMemoryApprovalNonceStore())
result, receipt = dispatch_with_approval_token(
    token,
    action_digest=action_digest,
    reviewer_role=reviewer_role,
    verifier=verifier,
    dispatch=lambda: "local result",
    now=now,
)

The callback has no token-layer arguments, which keeps the action payload out of approval diagnostics. The token is consumed before the callback starts and remains consumed if the callback fails. Persist the returned receipt as needed before reporting the action as approved or committed.

Exact-action and role binding

Pass the digest of the exact canonical action representation that the reviewer saw. When a governed side-effect preview is available, use its digest directly. Do not place action payloads, clinical values, record identifiers, reviewer identities, credentials, paths, or free text in reviewer_role or other token fields. A reviewer role uses this developer-authored form:

role:<reverse-domain>/<local-name>[@<version>]

The token layer accepts a digest rather than raw action content, so it cannot leak that content through exceptions or object representations. Avoid making a plain digest from a single low-entropy sensitive identifier; bind the complete canonical action or a separately governed preview artifact instead.

Replay protection and deployment scope

Every verifier requires an ApprovalNonceStore. Its claim() operation must be atomic for every process that can execute the protected action. InMemoryApprovalNonceStore is thread-safe but process-local, so it is suitable for one-process local workflows and tests. Multi-process or restarted services can inject SQLiteApprovalNonceStore using the same application-owned private local database file for every consumer:

from openmed.agent.approvals import SQLiteApprovalNonceStore

store = SQLiteApprovalNonceStore("approval-nonces.sqlite3", timeout=5)
verifier = ApprovalTokenVerifier(key, store)

Provision the parent directory privately before constructing the store; do not use a network filesystem or allow other users to replace the database. The store creates its file with mode 0600 and enforces owner-only permissions on POSIX. On Windows, provision an owner-only directory ACL. Each claim opens a connection, takes an exclusive transaction, purges entries whose integer expiry is at or before now, and inserts the nonce digest under a unique key. The rollback journal and synchronous=EXTRA make successful commits durable within SQLite's local filesystem guarantees. No connections need closing by callers. Only nonce digests and integer expiries are stored; tokens, signatures, keys, action digests, and reviewer roles are never persisted.

Reopening the same database preserves unexpired claims. Lock timeouts, corruption (including empty or truncated existing files), missing files during claims, invalid schemas, and unknown schema versions raise ApprovalNonceStoreError with controlled codes. Never recover by deleting or resetting a database while approvals remain valid: that discards replay protection. Keep the database through every outstanding token's expiry plus the shared verifier skew allowance, and deny dispatch when storage fails. The trusted clock must be consistent across consumers; moving it forward may purge entries that another consumer still considers unexpired. All consumers sharing a store must use the same trusted clock and skew policy.

ConsentReceiptVerifier keeps its existing process-local default. To protect receipts across MCP processes and restarts, inject the same claim protocol:

from openmed.mcp.consent_receipts import ConsentReceiptVerifier

consent_verifier = ConsentReceiptVerifier(
    key_provider, consumption_store=store,
)

The adapter hashes a domain-separated receipt ID and rounds fractional expiry up to integer seconds (current time is rounded down). This keeps a receipt claimed throughout its exclusive lifetime. Binding failures do not consume receipts, preserving the existing behavior. Store failures raise ConsentReceiptStoreError, also an ApprovalNonceStoreError, and verify_result() returns nonce_store_unavailable; the policy denies dispatch. is_consumed() and consumed_receipt_ids remain local diagnostic snapshots of this verifier's successful consumption, not queries of the shared database. The durable store does not alter issuance, token formats, or signing keys.

The HMAC key is also application-owned and stays local. OpenMed performs no network request, telemetry, or notification. Key lookup is delegated only to the application's injected local provider; persistence is opt-in through the injected store. HMAC is a shared-key primitive: any component that can verify with the key can also issue a token, so keep signing in the component that authenticates reviewers.

Key rotation and bounded validity

Issuance defaults to openmed.agent.approval_token.v2. key_id and issued_at are part of the canonical HMAC payload: changing either invalidates the signature. Keys are never derived from identifiers. key_id is a developer-authored, non-secret label, matching [a-z][a-z0-9._-]{0,127}; do not use key material, credentials, patient identifiers or private paths. Passing raw bytes is shorthand for the single key identifier default.

Inject an ApprovalKeyProvider with get_key(key_id) -> bytes | None, or use MappingApprovalKeyProvider over an application-owned mapping. Keep both current and retiring entries during rotation. Sign with the selected key_id; verification resolves each token's signed identifier. Removing the retiring entry immediately produces unknown_key, even for an otherwise valid token. A provider may return None or raise KeyError for unknown keys. Other provider failures are reduced to key_provider_unavailable, without source details. HMAC keys must be at least 32 bytes; providers and clocks must remain local.

Both signer and verifier accept max_lifetime_seconds (default 900, configurable from 1 through 86,400) and clock_skew_seconds (default 0, configurable from 0 through 300). Supply matching policy at both ends; a stricter verifier fails closed. The injected integer clock defaults to local Unix time. issued_at defaults to that clock but can be supplied explicitly. Policy checks enforce:

  • expires_at > issued_at;
  • expires_at - issued_at <= max_lifetime_seconds (skew never enlarges this ceiling);
  • issued_at <= now + clock_skew_seconds; and
  • now < expires_at + clock_skew_seconds (exclusive upper edge).

Issuance checks these bounds before generating a nonce or signing. Verification checks the signature and bounds before claiming the nonce. Nonce retention uses expiry plus skew, so a token consumed before expiry cannot be replayed during the tolerance window. The store protocol is unchanged. Token expiry plus skew must fit the supported signed 64-bit timestamp range.

Explicit v1 migration

v1 verification is disabled by default (legacy_token_disabled). For a short, application-controlled migration, configure ApprovalTokenVerifier(..., allow_v1=True, legacy_key_id="retiring") with the local provider. The default legacy key label is default. This flag verifies the original five-claim HMAC format, without adding unsigned v2 fields or guessing a key from the signature. ApprovalToken.from_dict and from_json also require allow_v1=True to parse v1 directly; direct token objects cannot bypass the verifier flag.

v1 has neither issuance time nor key identifier. Its historical total lifetime and not-before time cannot be proven. Compatibility therefore restricts its remaining validity (expires_at - now <= max_lifetime_seconds), applies the same exclusive expiry/skew bound and nonce retention, and selects only the explicit legacy key. Use v2 for full lifetime protection and disable compatibility after outstanding approvals have been renewed. Issuance never creates v1 tokens.

Protected execution authority

verifier.consume_authorization(...) performs the same signature, lifetime, role, action and atomic single-use checks as consume(...), then returns a protected ApprovalAuthorization. It holds the verified reviewer_role, consumed_at, exclusive expires_at including allowed skew, and the v2 receipt. Trusted local write adapters use these bounds and the verified role to recheck authority before dispatch. Direct construction is refused.

Choose one consumption method for each token; calling both is a replay. This authorization object stays in the protected, in-memory execution path and is not a serializable audit artifact or a replacement for fresh grant, scope, lineage and emergency-stop checks. Serialize only authorization.receipt. It contains no bearer token, signature or key, and its representation hides the protected role and times. A v2 receipt alone supplies no role or validity window and must not be treated as a complete execution authorization.

Value-free receipts and errors

Successful consumption returns ApprovalReceipt using openmed.agent.approval_receipt.v2. Its exact fields are schema_version, code (approved), action_digest, and token_digest (the SHA-256 digest of the complete signed token). This codes-and-digests-only receipt replaces the v1 receipt: reviewer roles and consumption/expiry timestamps are no longer receipt fields. It omits key identifiers, issuance time, nonce, signature, action payload, reviewer identity, and clinical values. Receipts are verification evidence, not proof that the callback committed successfully and not evidence that a clinical decision was correct.

Failures expose stable codes and fixed field names only:

  • invalid_signature for changed or incorrectly keyed tokens;
  • expired at or after expiry plus configured skew;
  • not_yet_valid when issuance is beyond the allowed skew;
  • lifetime_exceeded for an excessive signed lifetime;
  • unknown_key when the selected key has been removed or is unknown;
  • key_provider_unavailable when the injected provider fails;
  • legacy_token_disabled for v1 tokens without explicit compatibility;
  • replayed after a nonce has been claimed;
  • action_mismatch for a material action change; and
  • reviewer_role_mismatch for the wrong policy role.

Never log serialized approval tokens because they are bearer credentials. Safe diagnostics may record the failure code and field name. Verification and tests are deterministic when now and the nonce are supplied explicitly.

Run the focused offline tests with:

.venv/bin/python -m pytest tests/unit/agent/approvals/ tests/unit/mcp/test_consent_receipts.py tests/integration/test_durable_approval_nonces.py -q

Distinct-role approval quorums

ApprovalQuorumEvaluator evaluates existing, successfully consumed ApprovalAuthorization objects against an ApprovalQuorumPolicy keyed by a controlled action_class. This Python receipt-policy slice leaves token issuance, handoff leases and dispatcher wiring unchanged; it introduces no Swift token verifier or autonomous clinical action.

from openmed.agent.approvals import (
    ApprovalQuorumEvaluator,
    ApprovalQuorumPolicy,
    SQLiteApprovalQuorumStore,
)

clinician = "role:org.example/clinician@1.0.0"
pharmacist = "role:org.example/pharmacist@1.0.0"
requester = "role:org.example/trainee@1.0.0"
policy = ApprovalQuorumPolicy(
    action_class="high-impact-write",
    required_count=2,
    allowed_reviewer_roles=(clinician, pharmacist),
    distinct_roles=True,
    excluded_requester_roles=(requester,),
)
evaluator = ApprovalQuorumEvaluator([policy])
decision = evaluator.evaluate(
    action_class="high-impact-write",
    action_digest="sha256:" + "a" * 64,
    requester_role=requester,
    receipts=(),  # Supply live authorizations from consume_authorization().
    now=20,
)
assert decision.approved_count == 0
assert not decision.satisfied

Classes are developer-authored lower-case labels such as high-impact-write; unknown classes and duplicate policies fail closed. Counts must be positive. Policies can require distinct roles or count multiple unique receipts from one role. The actual requester role is always excluded; the policy's excluded_requester_roles additionally excludes designated requester-category roles even when they differ from the current requester. Impossible distinct-role policies are rejected at construction. An otherwise feasible policy may remain unsatisfied when the actual requester is one of its eligible roles.

Evaluation excludes different action digests, disallowed roles, requester roles, future consumption timestamps and receipts at or beyond their exclusive expiry. Repeated token digests within a supplied set exclude all copies, including copies with conflicting roles. replayed_receipt_digests accepts token digests already used elsewhere. A stateless evaluator cannot detect cross-call replay; use durable collection for incremental approvals. Public quorum entry points require the exact protected authorization type; audit ApprovalReceipt objects and caller-edited JSON are rejected. The store persists a separate closed metadata record, never a serialized authorization. Its role, validity bounds and audit receipt remain protected by host database integrity and are revalidated internally after restart. Roles do not identify people; hosts must authenticate reviewers and enforce person independence before issuing tokens.

Decisions serialize only action_digest, policy_digest, requester_role, reviewer_roles, receipt_digests, required_count and approved_count. receipt_digests are the existing receipts' token commitments, not bearer tokens. satisfied is a derived property, not an extra serialized field. Decisions contain no action class, source values, timestamps, reviewer identities or free text. The policy digest commits to all policy rules; role ordering does not change it.

Durable partial approvals

Create SQLiteApprovalQuorumStore with an application-owned, protected, dedicated local database path. Call collect with the evaluator arguments above plus a progress_digest: a stable opaque commitment to one logical action slot. Use separate slots for independent actions and reuse the slot when editing that action. Do not derive slots from lone sensitive identifiers.

# The application supplies the protected path and new verified receipts.
def collect_partial_approvals(database_path, verified_receipts):
    return SQLiteApprovalQuorumStore(database_path).collect(
        progress_digest="sha256:" + "c" * 64,
        evaluator=evaluator,
        action_class="high-impact-write",
        action_digest="sha256:" + "a" * 64,
        requester_role=requester,
        receipts=verified_receipts,
        now=20,
    )

Collection atomically preserves partial approvals across independent connections and restarts. A changed action digest, policy digest or requester role resets that slot's receipts. Global token-digest replay tombstones survive these resets, so reverting to an old action does not restore its approvals. Re-submission adds nothing; a previously collected receipt may still count as existing progress. Every submitted token digest is claimed, including ineligible submissions. Multiple eligible receipts for one role are retained for expiry renewal but count once under a distinct-role policy. Every read rechecks expiry; a database-wide clock watermark rejects rollback so expired approvals cannot revive.

SQLite transactions serialize collection and reset operations. Callers must supply the authoritative current action and serialize edits to the same logical slot; a stale caller must not restore an older action binding. This store does not replace revisioned handoff leases. Its decision is advisory and can be read repeatedly: hosts remain responsible for single-use effect authorization, revalidation at dispatch, reviewer authentication, database integrity and access control. Token issuance and existing single-token dispatch behavior are unchanged. No network calls, model outputs or restricted assets are added.

Storage contains only private validated evidence metadata, roles, digests and timestamps; errors use controlled codes such as store_unavailable and clock_rollback and omit the private path. Replay tombstones are retained without automatic pruning; hosts own database lifecycle and storage capacity. Never log token credentials or source action payloads. Malformed receipt data and storage failures fail closed.

Run the synthetic offline policy and persistence controls with:

.venv/bin/python -m pytest tests/unit/agent/approvals/test_quorum.py tests/integration/agent/test_approval_quorum.py -q