PII Walls - POC Plan
Status: draft · Owner: Chakshu · Companion to:2026-05-26-pii-chinese-walls-design.md, 2026-05-26-pii-at-rest-confidentiality-design.mdDecides: which spikes de-risk the two PII designs before build, and the go/no-go each feeds.
1. Scale anchor
members ~100k tenants (employers) ~1k providers ~2k
claims ~1M peak reads ~300 req/s detail-view fraction ~20% ⇒ ~60 detok req/s
budgets: security-stack added latency < +50ms p99 on a hot path
RLS overhead < +10% vs no-RLS, same query
vault unwrap p99 < 10ms bulk of ≤20 wrapped DEKs / callEarly-growth PMI book. roles-poc's 50k rows is close; bump synthetic data to ~1M so seq-vs-index is visible.
2. Principle
One POC per claim whose failure is only visible at scale and would force an architecture change. No POC for claims that are logically true (meet I2), cryptographically standard (AES-GCM, Shamir k-of-n), or governance (separation of duties) - reason about those.
3. The five
| # | Claim under test | Failure ⇒ redesign | Go/no-go @ scale (§1) | Effort | Status |
|---|---|---|---|---|---|
| 1 | OPA partial-eval row scope holds for production-shaped policies (multi-hop, disjunctive, large in) | disjunctive residual → seq scan; deep join → translator gap ⇒ hand-Go or SpiceDB | no-drift over ≥500 actors/role; index/index-only scan on 1M-row claim + joined tables; compile+query p99 < 25ms; residual gaps documented | ~1wk | extends /root/roles-poc (in flight) |
| 2 | RLS floor survives pgbouncer + per-checkout SET LOCAL GUC | txn-pooling resets/leaks GUCs ⇒ wrong-tenant rows (catastrophic) or forces session pooling; RLS predicate defeats index | 0 cross-tenant rows over ≥50k randomized checkouts at ~200 concurrent (txn pooling); RLS overhead < +10%; index scans intact | ~3-4d | ✅ GO (/root/rls-poc) |
| 3 | OpenBao Transit sustains detok QPS | unwrap latency/throughput bottlenecks Bao ⇒ DEK caching (key exposure) or HSM sooner | sustained ≥300 unwrap-ops/s (5× the ~60 req/s detail load) at p99 < 10ms on dev-class HW; Bao CPU headroom; bulk-of-20 path tested | ~3d | ✅ GO (/root/transit-poc) |
| 4 | RLS + OPA + vault compose within budget | each passes alone, sum p99 unacceptable | end-to-end added p99 < +50ms on "member opens claim detail" + "employer lists 50 then opens one", sustained @ 300 req/s | ~1wk | ✅ GO @ anchor (/root/composed-poc) |
| 5 | Blind-index β covers the searches the product needs | HMAC = exact-equality only; partial name/DOB search needs n-gram/prefix tokens that leak ⇒ UX-vs-confidentiality tradeoff late | enumerate search UIs (member by name/DOB/NHS#, provider, employer lookup); equality blind-index proven; leakage of any prefix scheme quantified; recommendation written | ~2-3d | ✅ GO w/ constraint (/root/blindindex-poc) |
Status 2026-05-27: all 5 closed. #1 GO (single-table
- single-hop joins, roles-poc), #2 GO, #3 GO, #4 GO @ anchor, #5 GO w/ constraint. No POC returned a no-go; the binding issues are engineering directives (1-statement RLS context, sidecar OPA, vault-domain placement of partial-match indexes), not architecture changes. Standalone code under
/root/{roles-poc,rls-poc,transit-poc,blindindex-poc,composed-poc}, each with FINDINGS.md.
4. Sequence & dependencies
#2 RLS+pgbouncer ──┐
#3 Bao Transit ──┤ (independent, run in parallel)
#1 OPA partial ──┘ (already in flight)
└──► #4 compose latency (needs 1-3 green)
#5 blind-index search (standalone, slot in anytime)Priority order #2 → #3 → #1, but #2 ∥ #3 ∥ #1 are independent and may overlap. #4 gates on the other three. #5 is decoupled.
5. What each POC decides
- #1 → keep OPA partial-eval for row scope, or fall back (hand-Go / SpiceDB) for the shapes it can't translate. (Phase 3 authz.)
- #2 → GO (
/root/rls-poc, FINDINGS.md). RLS floor viable behind txn pooling, conditional on tenant context viaSET LOCALinside the query's transaction - sessionSETleaked 99.8% of 50k checkouts; safe pattern leaked 0. Predicate is sargable (~0.8ms, same Bitmap Index Scan). Overhead <10% when the GUC is set in one statement (aSECURITY INVOKERfunction: +6.7-9.7% p50, ~0% p99); the 4-round-trip pattern is +67-110% relative but ~2-7ms absolute. Mandatory:NULLIF(current_setting('app.x',true),'')::int(recycled backends present unset GUC as''→ bare cast errors),FORCE ROW LEVEL SECURITY, app role ≠ table owner, noBYPASSRLS. - #3 → GO (
/root/transit-poc, FINDINGS.md). OpenBao Transit (2.0.2, same as prod) sustains ~24k single / ~404k batched unwrap-ops/s at p99 <10ms - 80×-1300× the target; ~80MiB RAM, saturates ~9 cores only at 400k/s. No DEK caching needed (good - caching would reintroduce key exposure). Caveat: this is Transit only; the vault's per-detok DB lookup + audit write (~1ms sync) will dominate over the ~3ms unwrap - composed cost is POC #4. - #4 → GO @ anchor (
/root/composed-poc, FINDINGS.md). Composed "open claim detail" (RLS→OPA→vault lookup→Transit unwrap→audit) p99 9.6ms (c=16) / 29.7ms (c=64) - within +50ms at ~300 req/s. Only at c=200 (~5k req/s, 16× peak) does it break (87.6ms p99), and the cause is shared OPA-over-HTTP (61ms p99), not the data/crypto layers. Directive: sidecar/cache/embed OPA for headroom; audit write is the largest cheap stage (keep sync per I4, batch later if QPS climbs). - #5 → GO w/ constraint (
/root/blindindex-poc, FINDINGS.md). Blind indexes are sargable, but leakage ∝ 1/cardinality: equality index on a 30-value surname over 100k rows reveals the frequency histogram 1:1; a 1-char prefix token covers ~21k people. So: equality bidx for high-cardinality identifiers (NHS#, email) in the open store; partial-name search index is sensitive-equivalent to the name → keep it in the vault trust domain gated byΦ, or restrict the UI to exact-identifier search. Cheap partial-name search and strong name-confidentiality are mutually exclusive on one store - pick per surface.
6. Not POC'd (reason, don't test)
I2 meet · AES-256-GCM strength · Shamir k-of-n mechanics (OpenBao-native) · separation of duties (governance) · I1/I3 as logical identities (they become production canaries, §9b of the access doc, not spikes).
