Skip to content

payment-vault

Status: Current | Last reviewed: 2026-08-14

The PCI capture boundary. This is the only component in the stack that ever sees a card number, and it holds one for the span of a single capture before exchanging it for a provider token. Everything it hands back is a token and a masked tail.

Read this page when you touch the capture surface, an adapter, the credential read, or the deployment. For the capability as a whole — the tools, the two gates, the policy composition — see Payments (PCI).

Source

apps/payment-vault

Runtime

Aspect Value
Language Python (FastAPI + uvicorn)
Port 3530
Health GET /health (unauthenticated — a probe needs it)
Replicas 1, and not as a starting point. See Why one replica
State Process memory. No Redis, no disk, no database writes

The one thing to understand first

A capture session is a dataclass in this process's heap and nowhere else — capture/session.py. Not Redis, not a table, not a cache. Every store is somewhere a PAN can be recovered from later: an RDB snapshot on a volume, a swapped page in a heap dump, a tombstone in a log-structured table. Holding the digits in one heap for five minutes is the smallest surface that still lets a caller type a card number.

Four consequences follow, and each is designed around rather than fixed:

  • A restart loses in-flight captures. The caller re-enters. That is why the TTL is short — there is nothing to recover, so there is no reason to keep it.
  • The TTL is a security control, not a UX knob. PAYMENT_VAULT_SESSION_TTL_SECONDS bounds how long a PAN can be held if every other mechanism fails. Raising it to be generous to a slow caller raises exactly that window.
  • A finalized session keeps only its outcome. finalize() discards every sensitive wrapper the moment the provider answers, so a session still in the map because the caller hasn't hung up holds a token and a last4.
  • sweep() returns removals, not expirations. A non-terminal session past its TTL is marked expired and emptied but kept, because an agent asking "did that go through" a minute later deserves an answer, and by then there is nothing sensitive left to keep. The row goes on a later pass. A test asserting sweep() >= 1 right after an expiry is asserting the wrong thing.

Four layers between a PAN and a log line

Ordered from the one that does the work to the one that catches the mistakes.

  1. sensitive.py — a card number is not a str. CardNumber, CardCvv, BankAccountNumber and friends wrap the value so repr, str, format, and JSON serialization all render a mask; reading the real thing takes an explicit .reveal(). This is the primary control: the ordinary ways of logging a value cannot leak one.
  2. The adapter boundary — create_payment_method is the only operation that takes digits, deliberately separated from charge so nothing downstream of tokenization needs them. Results may carry only ALLOWED_MASKED_KEYS, validated at the boundary, because some providers return a full PAN in test mode and must not be able to push it into a result the caller persists.
  3. log_guard.py — a loguru sink wrapper that rewrites every record before it reaches stderr, redacting digit runs long enough to be an account number (tolerating typed spaces and dashes), plus a hard refusal to log bodies on NEVER_LOG_PATHS (/internal/sessions, /internal/refund, /frame). It is blunt on purpose and will redact a long correlation id or a nanosecond timestamp: an over-redacted log costs a debugging session, an under-redacted one costs an audit. Log a numeric id you actually need with a non-digit separator (txn-4f2a).
  4. The database, one hop later — payment_transactions.masked_details accepts only a fixed key set, and payments_reject_long_digit_runs() refuses any detail object containing a run of seven or more digits. A bug in any service still cannot land a PAN.

A scrubber is a control that fires after the mistake and only catches shapes it knows, so layer 3 is never a reason to relax layers 1 and 2.

API

Two families, and the split is the security boundary: /internal/* is service-to-service behind a Bearer token, /frame/* is browser-facing behind a signed one-shot token.

Internal (Bearer + per-session nonce)

Route Purpose
POST /internal/sessions Open a capture. Resolves the provider credential here, before any digit is accepted, so a misconfigured integration fails while the agent is still talking rather than after sixteen digits
POST /internal/sessions/{id}/field Accept one field. A rejected value is a 200 with accepted: false, not a 4xx — the surface needs to re-prompt, which is a normal step, not an error
POST /internal/sessions/{id}/finalize Tokenize, then charge. A second finalize while one is in flight is refused rather than queued: two concurrent finalizes are two charges
POST /internal/sessions/{id}/cancel End and discard. Cancelling an already-terminal session succeeds — a caller retrying a cancel it never saw the response to must not be punished for doing the right thing
GET /internal/sessions/{id} Public state. Nonce-checked even though it only reads: digit counts and the amount are enough to follow somebody else's capture live
POST /internal/refund No session, because there is nothing to capture. conversation-api has enforced every limit by the time this is called

The nonce is minted once per session, returned once, and never readable back. conversation-api stashes it in Redis because it runs two replicas and a cancel may land on the one that didn't open the session — see payments/vault_client.py.

require_internal refuses every request when no strong token is configured. An unauthenticated vault is worse than an unavailable one, so "not configured" resolves to closed — and the lifespan logs an error about it, because otherwise the only symptom upstream is a 401.

Frame (browser)

GET /frame/{session_token} serves the hosted-fields page; POST /frame/{session_token}/submit takes the fields. The page is served by this service so its JavaScript is the only script that touches a PAN — an SAQ-A-EP posture rather than embedding a form in surface-app. PAYMENT_VAULT_PUBLIC_URL unset means no frame is offered, which is a legitimate voice-DTMF-only deployment: the session still opens and frame_url comes back empty. PAYMENT_VAULT_ALLOWED_FRAME_ANCESTORS empty renders CSP frame-ancestors 'none', the right default for a page whose only purpose is to be framed by exactly one known origin.

The page itself lands in Phase 4; the token minting, CSP, and origin checks are in place now.

Credentials

credentials.py reads the decrypted secret for the integrations row a payment names, and does nothing else with Postgres. Every statement is a SELECT, and DATABASE_URL should name a restricted, SELECT-only role — not a defence in depth so much as an accurate description of the service. tsf_app carries BYPASSRLS and would work, worse, for no benefit.

Three details verified against their authority rather than inferred:

  • MASTER_ENCRYPTION_KEY is not the AES key; the AES key is its SHA-256 digest, matching ai-manager's Node implementation and the orchestrator's encryption.py — which is what wrote the rows.
  • Node keeps the GCM auth tag in its own column; Python's AESGCM.decrypt wants it appended. Reassembling ciphertext + auth_tag is required, and getting it wrong yields InvalidTag, indistinguishable from a wrong key.
  • integration_secrets has FORCE ROW LEVEL SECURITY, so the GUC is set per query and the query carries an explicit tenant_id = $n predicate. If the vault is ever pointed at a BYPASSRLS role by mistake, the predicate is what still keeps one tenant's Stripe key out of another tenant's payment.

The 60-second cache exists so a voice capture doesn't make a round trip per field, not to shed load — so a credential rotation lands within a minute with no restart, the same TTL the knowledge and telemetry tenant stores use for the same reason.

When DATABASE_URL is configured, startup opens the credential pool and performs a non-secret SELECT 1 readiness probe. Failure does not crash-loop the PCI boundary: it is logged by error code, /health leaves credentials.pool_open=false, and the ordinary lazy credential path can retry. A successful fresh deployment therefore reports an open pool before any customer is asked to make the first payment.

Adapters

adapters/base.py defines four operations — create_payment_method, charge, refund, status — with three properties enforced on every implementation:

  • Results are data, not exceptions. A decline is a normal ChargeResult(succeeded=False) with a code. Only a failure to reach a verdict — network error, malformed response, missing credential — raises AdapterError. Conflating them turns a declined card into a 500 and the caller hears "something went wrong" instead of "that card was declined".
  • Money is minor units plus an ISO-4217 code. No floats anywhere, and no provider amount convention leaking upward.
  • Nothing returned may carry cardholder data.

status exists because ACH is asynchronous: a debit returning pending settles or fails days later. Shipped adapters are stripe (PaymentMethod / PaymentIntent / Refund, ACH via us_bank_account) and fake.

The fake needs two flags, deliberately. PAYMENT_VAULT_ADAPTER=fake and PAYMENT_VAULT_ALLOW_FAKE_PROVIDER=true. The second exists so a stray adapter setting in a deployed environment cannot quietly stop charging anybody; the chart leaves it false.

Why one replica is a correctness property

Sessions live in this process's memory, so a second replica answers half the requests for a session it has never heard of — an intermittent 404 mid-capture, not degraded throughput. More replicas is a bug, not headroom. The chart therefore ships replicaCount: 1 with the HPA off, and pdb.minAvailable: 0 because at one replica a stricter budget blocks node drains forever. Scaling this service needs sticky routing first.

Deployment

Helm chart at charts/payment-vault, wired into trueserve-stack with condition: payment-vault.enabled and an explicit payment-vault: {enabled: false} in charts/trueserve-stack/values.yaml — a condition: key absent from every values file leaves a subchart enabled, so the default has to be declared even though it's false. Verify with helm template against an empty values file, not by reading Chart.yaml.

The chart value and the capability's own gating are independent. There is no deployment-wide payments feature flag: availability is resolved per tenant by resolvePaymentsAvailability (payments-settings.ts) from the payments tenant-settings key, an active payments integration, and a resolvable vault URL. So deploying this pod exposes nothing by itself, and enabling the capability for a tenant does not deploy it.

The NetworkPolicy is hand-written, and has to be

templates/networkpolicy.yaml does not use base.networkPolicy, for two reasons that are both about egress:

  1. The base helper renders ipBlock: {cidr: …} with no except:, so the only way to reach a provider API through it is 0.0.0.0/0 — which also grants the pod every in-cluster address by IP. This is the one pod holding cardholder data, so the private ranges are excepted instead.
  2. The base helper adds no DNS rule. Naming any egress switches Egress enforcement on for the whole pod, so an enumerated policy built on the helper resolves nothing and the provider call fails looking like a DNS outage.

Ingress is default-deny: the internal API from ai-orchestrator and conversation-api only. The browser-facing frame arrives through the ingress controller, so networkPolicy.ingressControllerSelector is a separate value you must set in any environment that turns ingress.enabled on — leaving it unset blocks the form while every internal call keeps working, a failure that reads as a broken page rather than as a network rule. It defaults to unset because controller labels vary by distribution and a wrong guess is worse than an explicit gap.

Vanguard

overmind restart payment-vault
overmind connect payment-vault      # Ctrl+b then d to detach
curl -s localhost:3530/health | jq

Two things about start-payment-vault.sh are deliberate:

  • PYTHONPATH=. — the vault's modules import each other flat (from settings import settings), so uvicorn started from anywhere else can't resolve them. Same reason the tests run as PYTHONPATH=. .venv/bin/python -m pytest from apps/payment-vault.
  • No | tee /tmp/payment-vault.live.log. Every other service in the Procfile mirrors output to a file; this one sees cardholder data, and a log file on a dev box is exactly the residency the design spends its effort avoiding. log_guard.py already redacts, but a second copy of redacted output buys nothing while a gap in that redaction would now persist.

City environments expose the hosted frame at https://payment-vault.<city>.trueserve.ai through the repo-owned nginx template. Access logging is disabled for the entire vhost because the frame token travels in the URL; application logs use redacted route templates. city-update --step env sets that public URL, the exact surface-app frame ancestor, PAYMENT_VAULT_ADAPTER=auto, and PAYMENT_VAULT_ALLOW_FAKE_PROVIDER=false, then retrieves the internal and frame-token secrets from encrypted per-city SSM parameters without putting their values in command history.

The start script defaults the adapter to fake locally only — dev has no Stripe credentials, and the alternative is a service that boots fine and fails on first capture. The chart leaves allow_fake_provider false, so the default cannot travel.

Tests

cd apps/payment-vault && PYTHONPATH=. .venv/bin/python -m pytest tests/ -q

pytest, unlike conversation-api's plain-assert __main__ files. tests/test_pan_canary.py is the suite worth knowing about: it seeds one fixed PAN at every ingress and asserts it appears in no log line, response body, session repr, frame page, or provider request other than the single tokenization call — and asserts the positive too, because "the PAN is nowhere" is equally true of a service that dropped it and charged nothing. It carries its own control test proving the canary would in fact be detected, and a named inventory of the out-of-process leak targets (LLM context frames, publisher output, recorder audio, conversation_messages, Redis ui-state) against the phases that own them.

Two traps that suite documents, because both produced a failing test that was testing the wrong thing:

  • A 15-digit truncation of a 16-digit PAN is valid — it passes both the length and Luhn rules. A test needing a rejection wants a same-length Luhn-failing variant.
  • pytest's capture manager owns sys.stderr during the call phase, so a monkeypatch.setattr(sys, "stderr", …) in fixture setup is displaced and the failure is invisible under -s. Read through capsys (which drains, so accumulate).

Dependencies

Service Purpose
Postgres integrations / integration_secrets, read-only
Payment provider (Stripe) Tokenization, charge, refund, status — over TLS, egress-restricted

Nothing else. No Redis, no MinIO, no conversation-api call out — the vault is called, it does not call back.

See also