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¶
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_SECONDSbounds 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 assertingsweep() >= 1right 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.
sensitive.py— a card number is not astr.CardNumber,CardCvv,BankAccountNumberand friends wrap the value sorepr,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.- The adapter boundary —
create_payment_methodis the only operation that takes digits, deliberately separated fromchargeso nothing downstream of tokenization needs them. Results may carry onlyALLOWED_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. 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 onNEVER_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).- The database, one hop later —
payment_transactions.masked_detailsaccepts only a fixed key set, andpayments_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_KEYis not the AES key; the AES key is its SHA-256 digest, matching ai-manager's Node implementation and the orchestrator'sencryption.py— which is what wrote the rows.- Node keeps the GCM auth tag in its own column; Python's
AESGCM.decryptwants it appended. Reassemblingciphertext + auth_tagis required, and getting it wrong yieldsInvalidTag, indistinguishable from a wrong key. integration_secretshasFORCE ROW LEVEL SECURITY, so the GUC is set per query and the query carries an explicittenant_id = $npredicate. 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 — raisesAdapterError. 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:
- The base helper renders
ipBlock: {cidr: …}with noexcept:, so the only way to reach a provider API through it is0.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. - 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 asPYTHONPATH=. .venv/bin/python -m pytestfromapps/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.pyalready 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.stderrduring the call phase, so amonkeypatch.setattr(sys, "stderr", …)in fixture setup is displaced and the failure is invisible under-s. Read throughcapsys(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¶
- Payments (PCI) — the capability: tools, gates, policy composition, phase status
- conversation-api — the payments authority that drives this service
- Secret storage — how the Stripe credential is encrypted
- User guide: Taking payments