Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AAASM-5269 — Local-first sensitive-data provider architecture (Spike report)

Ticket: AAASM-5269 Epic: AAASM-5270 ADR: ADR 0032 (status Accepted, 2026-08-01) Surveyed against: main @ 77bd2bf9 Date: 2026-08

This is a research report. It recommends; it decides nothing. No production behavior and no public contract is changed by the branch that carries it. Every factual claim about the codebase carries a path:line; every performance claim is either a measurement taken for this Spike (with its environment stated) or is labelled an assumption.


0. Executive summary

The Epic asks whether to evolve the built-in credential scanner into a local-first sensitive-data architecture that can consult optional self-hosted providers. The answer is yes, but the ordering the Epic implies is wrong, for three reasons the survey and benchmarks establish:

  1. The provider question is not the urgent one. The measured behavior of the existing scanner on non-English input is a live defect (§4.1): 32 KB of ordinary mixed zh-TW/English agent traffic containing zero secrets produces 87 findings, and under credential_action: Block an agent communicating in Chinese is denied outright. No provider choice touches that code path. It should be fixed ahead of, and independently of, this architecture.

  2. External providers are disqualified from the synchronous path by physics, not by preference. Presidio costs 12.28 ms on a 592-byte tool call against the Rust scanner’s 5.8 µs — a ~2 000× ratio (§3.4) — and the out-of-process transport tax alone, with a provider that does no work at all, is over 7× the entire current scan for that payload class (§3.3). The same providers are, however, entirely reasonable for large or high-risk payloads handled asynchronously. The economics therefore prescribe the architecture: a deterministic in-process fast path, and an asynchronous deep path.

  3. The audit and analytics half of the Epic is in worse shape than the detection half, and is the part that silently misinforms operators. The durable audit table is write-only (§2.4), CredentialLeakBlocked does not mean “blocked” (§2.5), and an already-shipped API counts events where it claims to count findings (§2.6). A provider architecture layered on top of this would multiply, not fix, the problem.

The recommended architecture is Option 2 — a Rust deterministic core with a canonical finding model and optional local provider adapters — delivered in six phases, with the first two phases containing no provider at all.

Three items genuinely require Bryant’s decision; they are in §10 and none of them blocks the remaining research.


1. Existing Decision Summary

Required by .claude/skills/adr-governance/SKILL.md Step 4, produced before any design work.

### Existing Decision Summary
- Applicable ADRs / recorded decisions:
  - ADR 0015 — DLP Trust Boundary, Redaction Fail-Safety & Heuristic Detection Limits.
    The binding prior decision. Owns the redaction fail-closed rule, the golden-vector
    protection, and the explicit acknowledgement that detection is heuristic.
  - ADR 0018 — Canonical Runtime Verdict & Enriched Decision Record. Freezes a 5-way
    `RuntimeVerdict` (`Allow`/`Narrow`/`Scrub`/`Pending`/`Deny`) as the single verdict
    vocabulary, and states that populating it alters the enforcement/audit-write hot
    path and therefore requires product + architecture sign-off first.
  - ADR 0002 — SDK Security Boundary. Detection runs authoritatively in trusted layers.
  - ADR 0030 — Developer Integration Boundaries & Local Trust Model. Forbidden design
    #8 bans dynamic shared-library loading / implicit registration inside the trusted
    process; #7 bans loopback TCP for a local control surface, on the grounds that it is
    reachable by every local user with no kernel-supplied peer identity.
  - ADR 0024 — Empty Cascade Semantics. Establishes that adding an enum variant is
    *not* additive on the wire.
  - ADR 0026 — Open Dashboard Product Semantics. Decision 3 remains open and this work
    forces it.
  - ADR 0006 / 0009 / 0010 — self-host scope, image-tag pinning, distribution examples.
- Prior decisions that bear on this change: all of the above. ADR 0015 is the parent
  decision; ADR 0018 owns the outcome vocabulary any new event must not duplicate;
  ADR 0030 constrains the *form* an out-of-process provider may take.
- Conflicts with what this change would do:
  - The Epic's requested enforcement-outcome vocabulary (`mask`, `tokenize`,
    `require_approval`, `approval_granted`, `approval_denied`, `shadow_only`,
    `error_fallback`) is strictly richer than ADR 0018's frozen five, and ADR 0024
    says a new variant is not additive on the wire. Resolution proposed in §6.3;
    escalated as D-2 in §10.
  - "Provider architecture" is ambiguous between in-process in-tree adapters and
    external processes. The former conflicts with nothing; the latter needs ADR 0030's
    constraints honoured explicitly. Escalated as D-1 in §10.
- Missing decisions this change forces: the fast-path/deep-path split, the provider
  trust boundary and transport, the canonical finding taxonomy, provider failure
  semantics, and the sensitive-data event/analytics model. None is recorded anywhere.
- Proposed ADR action: **create** — ADR 0032, complementing ADR 0015 and ADR 0018.

Why create and not supersede or amend. ADR 0015’s own reconsideration trigger #2 anticipates exactly this work (“an upstream classifier”), so this is the successor it invited rather than a reversal of it. All five of its decisions survive intact and become invariants of the new architecture — most importantly the fail-closed redaction rule and the prohibition on editing a committed golden vector to make a detector change pass (0015:199-201). Amending would bury a cross-cutting deployment-topology decision inside a document about redaction semantics. ADR 0030 set the precedent for the shape: it created a new ADR rather than amending ADR 0002.

Next unused ADR number is 0032. The index at docs/src/adr/README.md:9 previously said “0005 never existed”; git history says otherwise — 0005-sdk-only-gateway-access.md was created (90679f35), then reframed and renamed by 643700e5 before the number was retired later. 0028 was used twice (0989bf9a, then 7b444d51, retired in bd867a23). Both gaps stay permanently empty either way, so the conclusion was unaffected; the index sentence and its stale active-ADR count are corrected on this branch. Highest existing is 0031.


2. Current-state architecture

2.1 The pipeline, end to end

agent action
   │
   ├─► LAYER 1  SDK (aa-sdk-client, in-process, UNTRUSTED)
   │      PolicyQuery ──────────────────────────────┐   no scan on this path
   │      EventReport ─────────────────┐            │
   │                                   ▼            ▼
   ├─► LAYER 2  aa-proxy (MitM HTTPS)  │   aa-gateway  EngineInner::evaluate
   │      scans body PRE-forward       │     engine/mod.rs:889  (sync fn)
   │      probe_adjudication.rs:144    │     Stage 6: self.scanner.scan(text)
   │      ForwardedPayload::NotForwarded     engine/mod.rs:1443  built-in + policy
   │      ── proves non-transmission          patterns merged → one findings list
   │         (probe branch only — §6.5)
   │                                   │            │
   │                          aa-runtime pipeline   │
   │                          pipeline/mod.rs:127   │
   │                          RuntimeScanner — runs ONLY on EventReport
   │                          i.e. POST-action, not pre-action
   │                                   │            │
   │                          enforcement.rs:268-271
   │                          *field = result.redact(field)
   │                                   │            │
   └─────────────────────────────────► ▼ ◄──────────┘
                                  AuditEntry
                                  (+ Redaction{credential_findings, redacted_payload})
                                       │
                    ┌──────────────────┴───────────────────┐
                    ▼                                      ▼
         per-session JSONL                    audit_bridge.rs:84-95
         hash-chained, full fidelity          audit_entry_to_storage_event
                    │                         ── 14 fields lost ──
                    │                                      ▼
                    │                            durable audit_events table
                    │                            query_audit_events: NO non-test caller
                    │                                    (write-only)
                    ▼
         analytics re-scan JSONL per request
         aa-api/src/routes/analytics.rs:373 — capped at 100 000 events
                    │
                    ▼
         GET /api/v1/analytics/agent-enforcement
         GET /api/v1/scrub/{patterns,pattern-counts,posture}
                    │
                    ▼
         dashboard — NOT wired to /scrub/* (§2.7)

2.2 The detector inventory

aa-security uses no regex at allaho-corasick = "1" is its only detection dependency (aa-security/Cargo.toml:10). Detection is five passes (scanner.rs:552): an Aho-Corasick literal-prefix pass over 28 patterns (aa-security/src/scanner.rs:14-54), a digit-sequence pass (credit card + US SSN), an email pass, a high-entropy pass, and an Azure AccountKey= pass.

Two in-repo comments still say “18 patterns” — aa-security/src/scanner.rs:545 and aa-gateway/src/engine/mod.rs:1437. Both are stale (AAASM-3727 added three GCP variants and AAASM-4128 added seven more). Correcting them is folded into B-2 in §9.

CredentialKind has 28 variants (scanner.rs:95-162), each with a category() (:210), a severity() (:245) and an as_str() redaction label (:277). 27 of them are built-in detectors enumerated by CredentialKind::ALL (:177-205), which deliberately excludes Custom. Those labels are a public contract: they appear in [REDACTED:<kind>] output pinned by 26 conformance vectors, and CredentialKind::ALL is exposed over HTTP by the shipped /api/v1/scrub/patterns.

PII coverage is exactly three detectors — CreditCardLuhn, EmailAddress, and a US-only SsnPattern. There is no Taiwan identifier of any kind, and no non-US PII.

The four dashboard fixture patterns with no detector at all are confirmed: AWS_SECRET, JWT, INTERNAL_URL, PHONE.

2.3 Fail-open / fail-closed, and where enforcement actually happens

The single most consequential correction the survey produced:

aa-runtime’s scanner is post-action. RuntimeScanner runs only on IpcFrame::EventReport (aa-runtime/src/pipeline/mod.rs:127). The pre-action PolicyQuery path never scans locally.

So despite aa-runtime being described as “the authoritative enforcement point”, the genuinely pre-transmission points are the gateway (engine/mod.rs Stage 6, before a decision is returned) and the proxy (before dial_upstream_tls). This matters directly for the Epic’s requirement that an event may only be called “prevented” when transmission provably did not occur (§6.5).

Related behaviors worth recording, each verified:

BehaviorEvidenceConsequence
redact_only — the default — collapses to a hard deny on the SDK pathaa-runtime/src/pipeline/mod.rs:656-671the default action is more severe than its name
the gateway’s redacted payload never reaches the wireaa-gateway/src/service/convert.rs:179 emits "$.{kind:?}", a JSONPathredaction is signalled, not delivered, on that path
no caller ever constructs a non-default ScannerConfigCredentialScanner::new passes ScannerConfig::default() (aa-security/src/scanner.rs:508); every other construction is under #[cfg(test)]the disabled kill switch and the literal custom-pattern slot are unreachable in production
the proxy’s JSONL audit writer is never instantiated in productionaa-proxy/src/lib.rs:70proxy/mod.rs:172 passes Noneproxy findings never reach disk
redaction fails closed on an unspliceable spanscanner.rs:443-461, returns "[REDACTED]"correct, and required by ADR 0015

2.4 The audit/storage bridge — the ticket’s claim, confirmed and exceeded

aa-gateway/src/storage/audit_bridge.rs:84-95:

#![allow(unused)]
fn main() {
AuditEvent {
    ts:               ts_from_ns(entry.timestamp_ns()),
    event_id:         event_id_for_entry(entry),
    agent_id:         entry.agent_id(),
    team_id:          entry.team_id().map(str::to_string),
    action:           entry.event_type().as_str().to_string(),   // ← same source
    decision:         entry.event_type().as_str().to_string(),   // ← same source
    dry_run:          false,                                     // ← hardcoded
    shadow_decision:  None,                                      // ← hardcoded
    matched_rule_id:  None,                                      // ← hardcoded
    payload,
}
}

The ticket’s assertion is verbatim correct, and understates the loss. The full inventory is 14 items; the ones that matter for this Epic:

FieldIn AuditEntry / JSONLIn durable AuditEventLost?
credential_findingsyes (kind + offset + label)absenttotal
redacted_payloadyesabsent — the raw payload is what persiststotal, and a privacy regression
hash chainyesabsenttotal
root_agent_id, parent_agent_id, delegation depthyesabsenttotal
org_idyesabsenttotal
session_idyesabsenttotal
policy_doc_idyesabsenttotal
action vs decisiondistinct conceptsboth = event typeconflated
dry_run / shadow_decision / matched_rule_idavailableconstantfabricated

Two consequences:

  • The durable table is write-only. query_audit_events and count_audit_events have no non-test caller anywhere in the workspace. Every analytics endpoint re-scans the JSONL files per request, capped at 100 000 events (aa-api/src/routes/analytics.rs:373) — and get_agent_enforcement calls the non-truncation-aware fetch, so it can silently return partial counts even though a truncated flag exists.
  • The persisted payload is the raw one, not the redacted one. That is a data -minimisation defect in its own right, independent of this Epic.

2.5 CredentialLeakBlocked does not mean blocked

The configured action becomes a proto Decision at aa-gateway/src/service/convert.rs:150-158, and that Decision becomes the recorded event type at aa-gateway/src/service/audit_service.rs:192-199:

Configured actionRecorded event typeWhat actually happened
redactCredentialLeakBlockedpayload scrubbed and forwarded upstream
hard blockPolicyViolationaction denied
alert_onlyToolCallInterceptedfindings present, counted as a clean allow

Any current figure derived from CredentialLeakBlocked is measuring redactions, most of which resulted in successful transmission of scrubbed bytes. A dashboard tile reading “leaks blocked” from this is wrong in the most dangerous direction — it reports prevention where there was forwarding.

2.6 Event-versus-finding conflation is already shipped

GET /api/v1/scrub/pattern-counts counts alerts by their first kind, not findings by kind: one StoredAlert is written per action (aa-api/src/alerts/mod.rs:195-211), its detected_pattern_type comes from primary_kind() — which is just kinds.first() (aa-gateway/src/alerts.rs:39-41) — and tally_by_kind then counts those alerts (aa-api/src/routes/scrub.rs:210-220). An action containing one AWS key and three emails increments one bucket by one. The distinction this Spike is asked to define is therefore not merely a future design concern — it is a live inaccuracy in a shipped API.

Separately, agent-enforcement never inspects dry_run, while transform_for_observe_mode rewrites Deny → Allow before the event type is chosen. Observe-mode decisions are counted as real enforcement, and errors occur in both directions.

2.7 AAASM-5174 — the Epic’s premise is out of date

The backend shipped. aa-api/src/routes/scrub.rs exists; the three routes are registered at aa-api/src/routes/mod.rs:263-265 and specified at openapi/v1.yaml:3378, :3413, :3441.

The dashboard was never wired to them. dashboard/src/ contains only the generated schema.d.ts types; no component issues a request. And the page still explains itself in the present tense using the now-false premise — dashboard/src/pages/ScrubPage.tsx:29-34:

“…has no route in aa-api at all (no scrub, dlp, redact, pattern or leak path exists in openapi/v1.yaml) and renders as an explicit absence carrying its reason.”

and dashboard/src/features/scrub/posture.ts:14-18:

“They are not-supported rather than unknown because waiting will not help: there is no DLP endpoint in aa-api at all…”

Three such paths now exist. This is an inverted truthfulness defect: the page declines to answer questions the backend can now answer, and justifies the refusal with a statement that is no longer true. AAASM-5112 and AAASM-5156 are genuinely complete; they were frontend-only honesty fixes and are not implicated.

Proposed disposition of AAASM-5174: remains valid, SPLIT. The backend half is correctly Done. The dashboard-wiring half was never ticketed and is now a correctness issue rather than a gap. See §9 backlog item B-1.

2.8 The regression net

The binding compatibility contract is 26 conformance vectors in conformance/vectors/credential_detection/, plus ADR 0015’s rule that a committed golden vector must not be edited to make a detector change pass (0015:199-201). The vectors assert start offsets and kinds directly; all end-span and coalescing behavior is pinned indirectly, through exact expected_redacted strings. Five of the 27 kinds have no vector at all, and aa-security/src/redaction.rs has zero tests.

There is exactly one perf gate on the hot path (engine/mod.rs:3749).


3. Measured performance

3.1 Environment

Apple M3 Max (16 cores), 128 GB, macOS 26.4.1, rustc 1.97.0 (2d8144b78 2026-07-07), release profile. Presidio in Docker Desktop 28.3.2 (linux/aarch64 VM, 4 vCPU / 7.75 GiB) with --memory=4g. Commit 77bd2bf9. Every fixture is synthetic.

Every measurement in this section has a committed harness. Reproduce with:

# §3.2 — the built-in Rust fast path
cargo bench -p aa-security --bench spike_5269_payload_classes    # criterion, throughput
cargo bench -p aa-security --bench spike_5269_percentiles        # true p50/p95/p99

# §3.3 — out-of-process transport floor (stand-in provider, no detection)
cargo bench -p aa-security --bench spike_5269_transport_floor

# §3.4 / §4.5 — Presidio, pinned by digest (never by a mutable tag)
docker run -d --rm --name aaasm5269-presidio -p 15001:3000 --memory=4g \
  ghcr.io/data-privacy-stack/presidio-analyzer@sha256:ae8f6f111ac2f04e3fec552f7f80edd0dcbfa2dd69ee1b9e030475be31669885
python3 scripts/research/aaasm-5269-presidio-probe.py

# egress-denied verification
docker network create --internal aaasm5269-noegress
docker run -d --rm --name aaasm5269-offline --network aaasm5269-noegress --memory=4g \
  ghcr.io/data-privacy-stack/presidio-analyzer@sha256:ae8f6f111ac2f04e3fec552f7f80edd0dcbfa2dd69ee1b9e030475be31669885

The three bench targets and the probe script are committed on this branch. They assert nothing and gate nothing.

Artifacts measured, pinned by digest because ADR 0032 forbids relying on a mutable tag:

artifactdigest / version
ghcr.io/data-privacy-stack/presidio-analyzersha256:ae8f6f111ac2f04e3fec552f7f80edd0dcbfa2dd69ee1b9e030475be31669885 (941 MB on-disk, 566 MB compressed)
ghcr.io/ai-agent-assembly/aa-runtimev0.0.1-rc.6 — 6.8 MB compressed, 14.4 MB on disk
toolchainrustc 1.97.0 (2d8144b78 2026-07-07)
repomain @ 77bd2bf9

3.2 The built-in Rust fast path

All nine rows the harness emits, from one quiesced run (scan only):

payloadbytesfindingsp50p95p99
small tool call, 1 finding44915.83 µs7.54 µs7.75 µs
small tool call, clean41004.83 µs6.25 µs6.42 µs
medium prompt, 32 KB32 9544379 µs404 µs428 µs
medium prompt, 32 KB, clean32 8000376 µs406 µs425 µs
large document, 1 MB1 049 045612.14 ms12.41 ms12.71 ms
large document, 1 MB, clean1 048 780012.15 ms12.49 ms12.75 ms
mixed zh-TW, 32 KB32 95391392 µs423 µs450 µs
mixed zh-TW, 32 KB, clean32 79987 ← see §4.1388 µs417 µs437 µs
high density, 500 findings59 300600922 µs979 µs1.02 ms

CredentialScanner::new() costs ~130–160 µs p50 across runs — a per-process fixed cost, not a per-request one.

Run-to-run variance on an unquiesced laptop is roughly ±10% at the p50 and much larger at the max — the harness prints a max column (not reproduced here) in which background load shows up as occasional millisecond outliers. The conclusions below depend on ratios spanning two to three orders of magnitude, so none of them is sensitive to that.

Three conclusions:

  • Throughput is a near-constant ~80 MiB/s across three orders of magnitude, i.e. cost is linear in bytes. For an Aho-Corasick automaton that is slow; the dominant cost is the entropy/digit/email passes, not the AC pass.
  • Finding count adds a real super-linear tail. 500 findings in 59 300 B costs 922 µs (600 findings — 500 planted credentials, plus the emails among them matching a second detector) against 379 µs for 32 954 B with 4 findings. Byte-linear extrapolation alone predicts ~682 µs, so input length accounts for roughly 56% of the 543 µs delta and finding count for the remaining 44% — about a 35% excess over the byte-linear prediction, from the sort and overlap-coalescing tail.
  • Redaction never dominates detection, but it is not free either. It costs a full payload to_string() plus a replace_range per coalesced span, so it scales with both payload size and finding count. The measured scan + redact versus scan delta was too unstable on an unquiesced laptop to quote as a range — across runs it moved from roughly −3% to +30%, with the largest excesses on the 1 MB and high-finding-count rows, which is the direction the implementation predicts. The stable result is the ordering, not a percentage: the budget to govern is detection. spike_5269_percentiles prints both tables side by side; compare them on your own hardware rather than relying on a single figure here.

3.3 Out-of-process transport floor

Measured with a stand-in provider that parses the request and returns an empty finding list — no detection whatsoever. These are lower bounds for any out-of-process design.

The harness’s small-payload fixture builds out to 592 B (the same construction the Presidio probe uses), so it is not byte-identical to §3.2’s 449 B row — the same caveat as §3.4, and immaterial at these ratios.

payloadJSON encode+decode onlyloopback TCP, persistentTCP, new conn/reqUDS, persistent
592 B0.58 µs43.79 µs61.58 µs9.08 µs
32 KB13.58 µs38.17 µs76.62 µs45.71 µs
1 MB427 µs539 µs590 µs1.13 ms

Set against §3.2, this is the decisive result of the Spike:

payloadin-process scantransport tax alonetax as % of scan
small tool call5.8 µs43.8 µs (TCP) / 9.1 µs (UDS)755% / 157%
32 KB379 µs38.2 µs (TCP)10%
1 MB12.1 ms539 µs (TCP)4%

Transport overhead dominates precisely where the synchronous enforcement path lives, and is negligible precisely where deep inspection is actually wanted. The architecture follows from the numbers rather than from taste.

These figures are less stable than §3.2’s: they involve the scheduler and the loopback stack, and on an unquiesced machine the UDS and 1 MB rows in particular move by 50% or more between runs. The small-payload TCP figure and the qualitative ordering reproduce reliably; treat the rest as single-run observations. Note that UDS beats loopback TCP by roughly 3–5× for small payloads but loses by ~2× at 1 MB (buffer sizing), and that a fresh connection per request adds 40–100% on top of the persistent-connection cost at small and medium payloads — so a provider transport must be a persistent connection, and for the small payloads that dominate, a Unix domain socket. That is the same conclusion ADR 0030 forbidden design #7 reaches from the security side, for unrelated reasons.

3.4 Presidio Analyzer, measured

Image ghcr.io/data-privacy-stack/presidio-analyzer@sha256:ae8f6f11…9885 (resolved from :latest on 2026-08-01; see the artifact table in §3.1).

metricmeasured
idle RSS746 MiB (752 MiB after load)
cold start to healthy, egress denied5.4 s
supported entities19, all US/UK-centric
image size (compressed, amd64)566 MB, of which one 409 MB layer is the spaCy model

For scale, the aa-runtime image is 6.8 MB compressed (14.4 MB on disk); one Presidio replica’s resident memory is ~50× its on-disk size and ~110× its compressed size.

Latency. The payload classes are the same, but the exact byte counts differ between the two harnesses (the Rust fixture is 449 B where the Presidio one is 592 B), so the ratios are approximate by construction — which is immaterial at three orders of magnitude:

payloadRust in-process p50Presidio p50ratio
small tool call (449 B Rust / 592 B Presidio)5.8 µs12.28 ms~2 000×
medium prompt 32 KB379 µs613 ms~1 600×
large document 1 MB12.1 msHTTP 500

Scaling is superlinear and then hits a wall:

bytesresultlatency
65 564OK, 443 findings1 524 ms
131 128OK, 886 findings3 830 ms
262 256OK, 1 772 findings11 331 ms
524 364HTTP 500 (bare HTML)
786 472HTTP 500 (bare HTML)
1 048 580HTTP 500 [E088] Text of length 1048580 exceeds maximum of 1000000

Only the last is a documented limit (spaCy’s nlp.max_length, which counts characters). The 524 KB and 786 KB failures are below that limit and return an unhandled HTML 500 — so an adapter cannot distinguish “too large” from “crashed” by status code, which bears directly on fail-open/fail-closed semantics.

Local-first is satisfied. On a docker network create --internal network Presidio reached healthy in 5.4 s, served a real /analyze request from inside the network, and could not reach https://pypi.org (URLError). Models are baked into the image. The caveat is that upstream’s own docker-compose.yml adds an ollama service that pulls models at runtime, so that compose file must not be copied.

3.5 Unmeasured, with a plan

ItemWhy not measuredHow to measure
provider concurrency / queueingsingle-client onlywrk-style N-client harness against the container; report saturation point
Gitleaks per-invocation spawn costbinary not installed; no server mode existsinstall pinned release, time gitleaks detect --no-git over the same fixtures
same-Pod sidecar vs cluster-local hopno cluster availablek3d/kind two-node; compare loopback vs ClusterIP p99
sidecar-per-Pod memory amplificationas abovearithmetic from §3.4 idle RSS × replica count; 20 pods ≈ 15 GB vs ~750 MB shared
Presidio Anonymizeronly Analyzer exercisedsame harness against /anonymize

The 4 vCPU Docker VM understates a production host, so Presidio’s absolute latencies are pessimistic. The ratios against the Rust path, taken on the same machine, are the durable result.


4. Language and locale

4.1 Defect: Traditional Chinese is systematically misclassified as secrets

Found while building the benchmark; root-caused and independently reproduced.

32 KB of benign mixed zh-TW/English agent traffic containing zero planted secrets yields 87 GenericHighEntropy findings. The byte-equivalent English yields 0. (The fixture is deliberately mixed rather than pure Chinese, because that is what real zh-TW agent traffic looks like — and because the defect is triggered precisely by a CJK run adjacent to an ASCII token.)

inputfindingsredact() output
請查詢訂單編號:ORD20260427001 的狀態1[REDACTED:GenericHighEntropy] 的狀態
please look up order id: ORD20260427001 status0unchanged
聯絡電話:0912-345-678,請於上班時間撥打1[REDACTED:GenericHighEntropy] — the whole string
文件連結:https://example.com/docs/guide 請參考1[REDACTED:GenericHighEntropy] 請參考

Measured false-positive rate by Han-character run length (2 000 randomly generated runs per length):

Han charsFP rate
13~34–51%
17~80–94%
20~95–99%

The rate is corpus-sensitive: sampling uniformly from a 100-character common-Hanzi pool gives the lower bound of each range, and sampling from the wider set used in the prose fixture gives the upper. The ranges above bracket both, and were independently reproduced during review. Unlike every figure in §3, this table has no committed harness — it is a one-off measurement, which is a further reason to read it as a shape rather than as calibrated numbers. The load-bearing claim is the shape — around half at 13 characters, most at 17, nearly all at 20 — not any single decimal, and it is insensitive to which pool is used. A fix ticket should pin one corpus and report exact figures against it.

Cause — three individually reasonable lines in aa-security/src/scanner.rs:

  1. :963 text.split_whitespace() — Chinese does not delimit words with spaces, so one “token” is an entire clause.
  2. :966-967 let len = token.len(); gated at (20..=64)str::len() is bytes, and a Han character is 3 UTF-8 bytes, so a 7-character Chinese phrase already sits inside the “looks like a secret” window.
  3. :878-894 shannon_entropy counts over s.as_bytes() while its own doc comment at :877 calls the result “bits per character” — an equivalence that holds only for ASCII. Han characters spread bytes widely, so byte entropy lands at 4.6–4.9 against the ENTROPY_BITS_GATE = 4.5 threshold (:903).

The gate’s calibration note names its corpus (:899): “…while English prose and snake_case / kebab-case identifiers stay below this.” The assumption was documented, English-only, and never revisited.

End-to-end impact. On the enforcement path (aa-runtime/src/pipeline/enforcement.rs:268-271, *field = result.redact(field)), 客戶反映系統登入失敗請協助處理謝謝 becomes [REDACTED:GenericHighEntropy]. Under credential_action: Block, an agent communicating in Chinese is denied outright. The failure is language-discriminatory and generalises to every space-less script — Chinese (both scripts), Japanese, Thai. It also floods the audit trail: each false positive is a real CredentialFinding flowing into Redaction, the audit entry and the alert store, so any “leaks detected” figure for a zh-TW tenant is dominated by noise.

Why it survived. rg '[\x{4e00}-\x{9fff}]' conformance/ aa-security/ returns nothing on main. All 26 vectors are ASCII. The suite does carry a false-positive guard — entropy_false_positive_clean.json — but its input is "The quick brown fox jumps over the lazy dog. This is a normal log message with no secrets." A zh-TW sibling would have caught this on the day the gate landed.

Proposed fix — one line, no contract change: skip tokens containing any non-ASCII byte in the entropy pass. A base64 or hex secret is ASCII by definition, so this weakens no detection and breaks no vector. It belongs to a Bug ticket sequenced ahead of the ADR (§9, B-2), because no architecture choice affects this code path.

This report deliberately does not add the CJK conformance vector: written truthfully it fails against current behavior and would turn CI red from a research ticket; written to match current behavior it would enshrine the defect. It is the fix ticket’s regression test.

4.2 Two further defects in the same area

  • Full-width digits are invisible. 4532… (U+FF10–19) is not seen by CreditCardLuhn or SsnPattern. This is a live evasion and is not zh-TW specific.
  • String::from_utf8_lossy + into_bytes() write-back (aa-runtime/src/pipeline/enforcement.rs:289-294) corrupts chunk-split CJK payloads.

A hypothesis worth recording as refuted: byte offsets cannot slice a CJK codepoint mid-character, because every detector predicate is ASCII-only and UTF-8 guarantees ASCII bytes never appear inside a multi-byte sequence. redact() additionally guards with is_char_boundary.

4.3 Taiwan entity catalogue — deterministic vs probabilistic

EntityFormatValidationTier
國民身分證統一編號1 letter + 9 digitsletter→2-digit map + weighted mod-10deterministic
居留證 (2021 form)1 letter + 9 digits, d₁ ∈ {8,9}identical algorithm to national IDdeterministic
居留證 (legacy)2 letters + 8 digitsseparate mapdeterministic
統一編號 (business)8 digitsweighted checksum; changed mod-10 → mod-5 on 2023-04-01deterministic
Mobile09xx-xxx-xxx, +886 9…format only, no checksumdeterministic (weak)
Landline0x-xxxxxxxx by area codeformat onlydeterministic (weak)
Address縣/市/區/路/段/巷/弄/號/樓structuralpartial / probabilistic
Chinese personal name2–4 charssurname gazetteer + contextprobabilistic
健保卡號no stable public algorithmnot reliably detectable

Two traps worth calling out because they cause silent misses:

  • The 2021 ARC uses the same algorithm as the national ID and differs only by the first digit, so a naive d₁ ∈ {1,2} filter silently misses every foreign resident.
  • 統一編號 changed to mod-5 on 2023-04-01, so a legacy-only detector misses every business registered since.

Residual false-positive rates are real and must be stated rather than hidden: 統一編號 ~22% of random 8-digit strings pass the checksum; national ID ~10%; phone and passport have no checksum at all. Context keywords (身分證/統編/電話) are what make these usable, not the checksum alone.

4.4 \b does not work against CJK

Han is \p{Alphabetic}, therefore \w, so \b\d{8}\b does not match 統編12345675 — which is exactly how the identifier is written in practice. Rust’s regex has no lookaround, so boundary checking must be manual (!c.is_ascii_alphanumeric()), not \b. This affects aa-gateway’s policy patterns (regex = "1", Unicode defaults on) as well as any new recognizer.

4.5 What the providers offer for zh-TW: nothing

Measured, not inferred:

request to Presidioresult
language: "zh"HTTP 500 {"error":"No matching recognizers were found to serve the request."}
language: "zh-TW"HTTP 500, same
Chinese text as language: "en"HTTP 200, 0 findings

The third row is the dangerous one: Presidio does not error, it returns a clean result. An adapter that falls back to en on an unsupported locale would report “no sensitive data” for every Chinese payload. An unsupported locale must be an explicit capability miss, never a clean scan.

Taiwan identifiers, submitted as en, are confidently mislabelled:

synthetic inputPresidio returns
身分證字號 A123456789US_DRIVER_LICENSE(0.30)
統一編號 12345675US_BANK_NUMBER(0.05), US_DRIVER_LICENSE(0.01)
手機 0912345678DATE_TIME(0.85), PHONE_NUMBER(0.40)

Note the last row: the highest-confidence label is DATE_TIME. A “take the top-scoring entity” adapter would classify a Taiwanese phone number as a timestamp.

Presidio’s catalogue includes Thailand, Korea, Singapore and the Philippines but no Taiwan and no Chinese recognizers. zh_core_web_* models are Simplified-trained on OntoNotes 5 (sm 46 MB / F 68.42; trf 396 MB / F 74.26), giving a realistic zh-TW estimate of ~0.62–0.70 — and zh_core_web_lg alone is a 603 MB wheel. Gitleaks has no locale dimension and no PII scope at all.

Conclusion: Taiwan detection must be first-party Rust. It is regex plus arithmetic plus a ~60-word context dictionary and a 22-entry gazetteer — no model is required — and it is the only option that can run in the in-process SDK layer and in WASM, where Presidio cannot run at all.

Taxonomy recommendation: locale-qualified NATIONAL_ID[zh-TW/arc_new] rather than new CredentialKind variants, so policies need no per-locale rewrite and CredentialKind::ALL — now a published API surface — stays stable.


5. Provider feasibility

5.1 Matrix

Built-in RustPresidioGitleaksCustom local protocol
Licenseproduct-ownedMITMITproduct-owned
Governanceusdata-privacy-stack, community-run — no longer Microsoftzricethezav/gitleaksus
Invocationin-process libraryHTTP serviceCLI, file-orientedour choice
Small-call latency6 µs12.3 msprocess spawn (unmeasured)transport floor 9.1 µs UDS / 43.8 µs loopback TCP
Large payload12.1 ms @ 1 MBfails ≥ 524 KBfile-oriented, finen/a
Idle RSS~0 (shared automaton)746 MiB~0 between invocationsprovider-defined
Offline / egress-denynativeverified working, models baked innativeby construction
zh-TWnone today; buildablenone, and fails closed-cleann/a (secrets only)by construction
Offsetsbyte offsetschar offsetsline/columnmandate byte offsets
Confidencenone (binary)0–1 score, poorly calibrated outside USnone (entropy only)mandate a band
Returns raw secret?non/a (PII spans)YES by defaultFinding.Secret unless --redact=100forbid
Attestationsn/aSPDX SBOM + SLSA provenanceSLSA provenance, no SBOMn/a
Sync pre-actionyesnonoonly in-process
Async deep pathyesyes, with a size ceilingyesyes

5.2 Rejected candidates

  • TruffleHog — AGPL-3.0, and its headline feature is outbound credential verification, which is a direct violation of the local-first constraint.
  • detect-secrets — Python runtime for a strict subset of Gitleaks’ coverage.
  • ripsecrets — stale; port the rules instead of taking the dependency.
  • Presidio transformers/GLiNER extras — runtime Hugging Face downloads break egress-deny.
  • Presidio as a per-Pod sidecar — ~15 GB resident across 20 pods versus ~750 MB for one shared service, for a provider that must not be on the synchronous path anyway.

5.3 Deployment topologies

TopologyIsolationLatencyAmplificationVerdict
in-process Rustnone (same address space)0nonefast path — default and always available
local child processprocess~9 µs UDS1× per hostgood for a custom provider
container, Docker Composecontainer~44 µs loopback TCP1× per hostthe self-host example shape
same-Pod sidecarcontainer, shared netnsloopback× replica countonly for tiny providers; never Presidio
cluster-local Deploymentpod + networkreal network hop1× per clusterthe K8s answer for heavy providers
node-local DaemonSetpodloopback-ish× node countmiddle ground; not needed at current scale
external user-managedfullunboundeduser’s problemallow, validate only

Decision rule. Use a same-Pod sidecar only when the provider’s resident memory is small enough that multiplying it by the replica count is acceptable and per-request latency is genuinely critical. Otherwise use a cluster-local shared service. Presidio at 746 MiB fails the first test and, being ineligible for the synchronous path, does not need the second — so Presidio is shared-service-only.

Per repo policy, Helm/Terraform/Kubernetes production orchestration is a research/ADR question here, not committed implementation work. Docker Compose examples are in scope.

5.4 Provider lifecycle — automate vs validate

The boundary matters more than the mechanism.

ConcernAgent Assembly should
declarative provider manifestown — schema, parsing, validation
capability discoveryown — query, cache, expose
image/artifact resolutiongenerate assets and validate digests; never pull silently
digest / signature verificationown — verify before use, fail closed
start / stop / restartvalidate and report only for containers; may own a child process it spawned
readiness / liveness / smoke testown
resource reportingown
upgrade / rollbackgenerate the change; the operator applies it
runtime egress policygenerate and verify, deny-by-default
installing Docker, obtaining root, pip installnever

ADR 0030’s forbidden designs constrain the form rather than prohibiting out-of-process providers, and two of them point the design in a specific direction:

  • #8 bans dynamic shared-library loading and inventory-style registration inside the trusted process. This argues for out-of-process providers over in-process dynamic plugins — an external sensor is precisely not unreviewed code in the trusted address space.
  • #7 bans loopback TCP for a local control surface, because it is reachable by every local user with no kernel-supplied peer identity. Applied here, a local provider transport should be a Unix domain socket with peer-credential checks, not 127.0.0.1:port. The measurement in §3.3 independently favours UDS for small payloads, so security and performance agree.

5.5 Threat model

ThreatMitigation
provider compromised, returns crafted spansspans validated against payload length and char boundaries; redaction already fails closed (scanner.rs:443-461)
provider compromised, exfiltrates payloadegress deny-by-default (verified achievable, §3.4); no provider gets network
provider unavailable / times outexplicit per-risk-class policy; never silently “clean”
provider returns raw secret (Gitleaks default)adapter must set --redact=100; adapter rejects any response containing raw match text
unsupported locale treated as cleancapability miss ≠ clean scan (§4.5)
malicious image / tag mutationdigest pinning + signature verification before use
socket reachable by other local usersUDS + peer credentials, 0600, per-instance path
span mismatch corrupts payloadexisting fail-closed path; property test across scripts
raw values leak into logs/metrics/tracesprohibited by construction; §6.4

6. Sensitive-data event and analytics model

6.1 What the current system can and cannot answer

The Epic’s motivating question — “how many findings of each class did Agent X attempt to send to Tool Y, how many were blocked versus redacted, and how many were uncertain?”cannot be answered today, and not for one reason but four:

  1. findings never reach durable storage (§2.4);
  2. destination/tool is not a dimension of the audit event at all;
  3. “blocked” and “redacted” are not distinguishable from the event type (§2.5);
  4. there is no notion of uncertainty — findings are binary.

6.2 The three proposed records

SensitiveDataDecisionEvent (one per inspected action), SensitiveDataFindingRecord (child rows), and SensitiveDataAnalyticsRollup (pre-aggregated). Full field lists, Rust sketches, JSON examples and SQL shapes are carried in the ADR; the load-bearing choices are:

  • Findings become normalized child rows, not JSON blobs — because every headline metric groups by category, and a JSON blob cannot be indexed for that without a second projection.
  • The event carries finding_count and finding_count_by_category denormalized, so the common dashboard query needs no join.
  • Schema version is explicit on every record.

6.3 Outcome vocabulary — the ADR 0018 interaction

ADR 0018 froze a 5-way RuntimeVerdict and ADR 0024 established that adding an enum variant is not additive on the wire. The Epic’s requested vocabulary is richer. Extending the frozen enum would be a breaking wire change to a deliberately frozen contract.

Proposed resolution: keep RuntimeVerdict as the coarse, frozen, wire-visible verdict, and carry the finer vocabulary in a separate additive field, sensitive_data_disposition, on the new event only. Scrub remains the RuntimeVerdict for every transforming disposition; the new field distinguishes redact / mask / tokenize beneath it. Nothing about ADR 0018 or 0024 has to move. Escalated as D-2 because it is a public-contract shape, not purely a technical call.

6.4 Privacy-safe evidence

  • Raw values never enter logs, metric labels, traces, or dashboard payloads. This is unconditional.
  • Offsets and lengths are not automatically safe. A length plus a category can identify a value in a small domain, and an offset into a known template can help reconstruct it. Recommendation: offsets/lengths in the tamper-evident audit tier only; never in the analytics projection or any API response.
  • Field paths are safe and are the right drill-down granularity.
  • Tenant-keyed HMAC fingerprints are rejected for PII. A Taiwan national ID has ≈5.2 × 10⁸ candidates — enumerable in under a second on one GPU given the tenant key. Fingerprinting is only defensible above roughly 80 bits of value entropy, which excludes every PII category and admits only long random secrets. This must not be offered as a general “repeat exposure” feature.
  • Cardinality: the allowed metric label set is {category, severity, confidence_band, outcome, detection_method, provider_id} — all bounded. agent_id, destination, session_id, trace_id and any fingerprint are forbidden as metric labels and belong to the queryable event store.
  • Sampling: high-risk findings and every enforcement failure are never sampled. Clean allow events may be sampled or rolled up.

6.5 The prevention rule

An event may be counted as prevented transmission only when all four hold:

  1. the enforcement point is pre-transmission (gateway or proxy — not aa-runtime, §2.3);
  2. the decision was deny or a transforming disposition;
  3. an explicit execution-evidence observable records that the action did not reach its destination;
  4. the action was not in observe/dry-run mode.

The observable in (3) already exists: ForwardedPayload::NotForwarded (aa-proxy/src/probe_adjudication.rs:144), returned before the dial_upstream_tls on that path. dial_upstream_tls is defined once (aa-proxy/src/proxy/mod.rs:367) but called from two sites (:678, :912), and ProbeAdjudication::new (:881) precedes only the second — so today the observable exists only on the protection-probe branch for a Block verdict, and is never persisted. Generalising it to every pre-transmission decision and persisting it is the smallest change that makes a truthful prevention metric possible.

Everything else is detected, not prevented. Note that redaction forwards scrubbed bytes, so a redacted action is a transformed transmission, not a prevented one — a distinction the current CredentialLeakBlocked naming actively obscures.

6.6 Metric dictionary

MetricNumeratorDenominator
inspected_action_countactions that completed inspection
inspection_coverage_rateinspected eligible actionseligible actions observed
event_countdecision events
finding_countfinding records
blocked_event_countevents with outcome block
blocked_finding_countfindings contained in blocked events
redacted_event_countevents with a transforming disposition
redacted_finding_countfindings actually transformed
suspected_finding_countfindings with status suspected/needs_review
provider_disagreement_countfindings where ≥2 providers disagree on category
uncertain_finding_ratesuspected + disagreementfinding_count
prevention_rateevents meeting all four §6.5 conditionsactionable sensitive-data events
provider_timeout_count etc.per provider, per outcome

Worked example — one action, three findings, two redacted and one that caused a block:

event_count                += 1
finding_count              += 3
blocked_event_count        += 1     (the action was blocked)
blocked_finding_count      += 3     (all 3 were in the blocked action)
redacted_event_count       += 0     (the action was blocked, not redacted)
redacted_finding_count     += 2     (2 were transformed before the block decision)
prevention_rate numerator  += 1     ONLY if NotForwarded evidence is present

redacted_event_count = 0 alongside redacted_finding_count = 2 is the canonical illustration of why the two counters cannot be collapsed.

It also forces one definition to be stated explicitly, because the obvious reading is wrong: redacted_finding_count counts transformation operations performed, not findings whose redacted form was transmitted. On a blocked action nothing reaches the wire, so those two transformations were discarded. Without this stated, a reader would reasonably interpret “2 redacted” on a blocked action as “2 secrets were scrubbed and sent”, which is the opposite of what happened. If a “redacted and delivered” figure is ever needed, it is a distinct metric conditioned on execution evidence, not this one.

6.7 Storage tiers

TierContainsRetentionWho reads
tamper-evident audit (JSONL, hash-chained)full fidelity incl. offsetslongcompliance export, forensics
queryable security events (durable)event + finding rows, no offsetsmediumAPIs, drill-down
metrics / time-seriesbounded labels onlyshort-mediumalerting, SLOs
rollupspre-aggregated dimensionslongdashboard, reporting

The lossy audit_entry_to_storage_event bridge should not be extended field-by-field. It should be superseded by a dedicated sensitive-data projection written alongside it, leaving the existing bridge untouched until the new projection has a consumer — which also finally gives the durable tier a reader.


7. Architecture options

Option 1 — Extend only the built-in Rust scanner

Cheapest, keeps everything in-process, no new trust boundary, works in WASM and the SDK layer. But the coverage ceiling is real: no NER, no semantic classification, and every recognizer is ours to maintain forever. §4.1 also shows the existing heuristic layer is mis-calibrated for non-English input, so “just extend it” starts from a defect.

Rejected as the whole answer; adopted as the core of the answer.

Deterministic Rust fast path always available and always authoritative for the synchronous decision; a canonical, provider-neutral finding model; capability-based routing; optional local adapters (custom protocol, Presidio, Gitleaks) consulted asynchronously for large or high-risk payloads; Agent Assembly owns aggregation, policy, approval and audit throughout.

Directly supported by the measurements: §3.3 shows transport cost is prohibitive exactly where the fast path lives and negligible where deep inspection belongs.

Option 3 — Replace the scanner with one external framework

Disqualified by measurement. Presidio is ~2 000× the fast path on the dominant payload class, hard-fails above ~524 KB, has zero Chinese support with a silent-clean failure mode, cannot run in the SDK or WASM layers, and adds 746 MiB resident plus a Python runtime to a 6.8 MB image. It also inverts the trust model the product is built on.


8. Migration

Six phases. The first two contain no provider at all.

PhaseContentBehavior changeRollback
0fix the CJK entropy defect + full-width digits; add CJK conformance vectorsyes — a bug fixrevert; vectors are new
1canonical finding model wrapping the existing scanner, 1:1 with CredentialKindnonetype-only revert
2provider port + in-tree test double; formalise the seam already hardcoded at aa-gateway/src/engine/mod.rs:1443none (test double only)remove trait impl
3sensitive-data decision event + durable projection, written alongside the existing bridgeadditive onlystop writing the projection
4optional local adapters behind explicit config, default off, async deep path onlyopt-in onlyconfig flag
5risk-based escalation, provider budgets, API/dashboard semanticsgated on shadow-mode agreementfeature flag

Compatibility invariants that hold across every phase:

  • CredentialKind variants and as_str() labels are frozen — they are pinned by 26 conformance vectors and exposed by /api/v1/scrub/patterns.
  • No committed golden vector is edited to make a change pass (ADR 0015).
  • Redaction stays fail-closed.
  • RuntimeVerdict stays the 5-way frozen enum (§6.3).

Shadow mode must segment by script, or the zh-TW false positives of §4.1 will swamp the comparison signal until Phase 0 lands.


9. Proposed backlog

Proposed only — not created, per the ticket’s instruction to wait for ADR and product review.

IDTitleDepends on
B-1✨ (dashboard): Wire the Scrub surface to the shipped /api/v1/scrub/* routes
B-2🐛 (aa-security): Stop classifying non-ASCII text as high-entropy secrets
B-3🐛 (aa-security): Normalise full-width digits before Luhn/SSN detection
B-4🐛 (aa-runtime): Preserve non-UTF-8 and chunk-split payloads on redaction write-back (enforcement.rs:289-294)
B-5✅ (conformance): Add CJK and full-width false-positive vectorsB-2, B-3
B-6✨ (aa-security): Canonical sensitive-data finding model over the existing scannerADR 0032
B-7✨ (aa-security): zh-TW deterministic recognizer packB-2, B-6
B-8♻️ (aa-gateway): Formalise the detection seam at engine/mod.rs:1443 behind the provider portB-6
B-9✨ (aa-core): SensitiveDataDecisionEvent + finding recordsADR 0032, B-6
B-10✨ (aa-gateway): Durable sensitive-data projection alongside the audit bridgeB-9
B-11✨ (aa-proxy): Persist ForwardedPayload::NotForwarded as execution evidenceB-9
B-12✨ (aa-api): Sensitive-data analytics + drill-down endpointsB-10, B-11
B-13✨ (aa-*): Local provider protocol + in-tree test doubleADR 0032, B-6, follow-up ADR (D-1)
B-14✨ (aa-*): Presidio adapter, async deep path, opt-inB-13
B-15✨ (aa-*): Gitleaks adapter with mandatory --redact=100B-13
B-16✨ (dashboard): Sensitive-data analytics viewsB-12

B-13B-15 are the out-of-process work that D-1 has now deferred out of v1 (resolved 2026-08-01). They are recorded as post-v1 scope only, gated on a future provider ADR, and must not be started during the v1 programme.

B-1  (independent — correctness, ship now)
B-4  (independent)

B-2 ──┬──► B-5
B-3 ──┘

ADR 0032 ──► B-6 ──┬──► B-7   (also needs B-2)
                   ├──► B-8
                   ├──► B-9 ──┬──► B-10 ──┬──► B-12 ──► B-16
                   │          └──► B-11 ──┘
                   └──► B-13 ──┬──► B-14
                               └──► B-15

B-13 additionally requires the D-1 follow-up ADR (§10), which is why B-13..B-15
cannot start on ADR 0032 alone.

10. Decisions required from Bryant — ALL RESOLVED 2026-08-01

Resolved. All three were answered on 2026-08-01 (AAASM-5343) and are now recorded in ADR 0032 §10, which is Accepted. The recommendations below were adopted in full:

  • D-1 → NO. Out-of-process providers are not in v1. B-13/B-14/B-15 are deferred post-v1 and must not be started.
  • D-2 → keep RuntimeVerdict frozen, add an additive sensitive_data_disposition field.
  • D-3 → fix the zh-TW defect in agent-assembly v0.0.1-rc.7, ahead of the migration, with CJK conformance coverage and no weakening of ASCII secret detection.

The original framing is kept below unchanged, because the evidence and the options are what justify the answers.

Only these three. Everything else in this report was derivable from code, measurement or first-party sources, and none of these blocks further research.

D-1 — Does “provider architecture” include out-of-process providers in v1?

Why it can’t be derived technically. Both readings are defensible and the answer determines the ADR’s blast radius, not its content.

A — in-process, in-tree adapters only (v1)B — out-of-process providers in v1
ADR impactADR 0032 create; nothing else movesADR 0032 plus explicit reconciliation with ADR 0002 / 0030
Trust boundaryunchangednew: a sensor process outside the trusted layer
Presidio/Gitleaksnot usable at allusable, async only
Risklowmoderate, and needs the UDS + peer-credential shape of §5.4

Evidence: §3.3 shows out-of-process is only economical for large payloads; §3.4 shows Presidio is only viable asynchronously. So B buys exactly one thing — third-party engines on the deep path — and A costs exactly that.

Recommendation: A for v1, B behind a follow-up ADR. Phases 0–3 of §8 contain no provider at all and deliver most of the Epic’s value (the zh-TW fix, the canonical model, the event/analytics layer). Deferring B loses nothing on the critical path and keeps three accepted ADRs untouched while the model settles.

Consequence of delay: none for Phases 0–3. Phase 4 cannot start.

D-2 — How should the finer enforcement vocabulary relate to the frozen RuntimeVerdict?

Why it can’t be derived technically. ADR 0018 deliberately froze five variants and ADR 0024 says adding one is not additive on the wire. Choosing to break that is a product call about contract stability.

  • A — keep RuntimeVerdict frozen; carry mask/tokenize/approval_*/ shadow_only in a separate additive field on the new event.
  • B — extend RuntimeVerdict, accepting a breaking wire change and reopening ADR 0018 and 0024.

Recommendation: A. It costs one extra nullable field and preserves two accepted ADRs plus every existing consumer. Consequence of delay: B-9 cannot be specified precisely, though the rest of Phase 3 can proceed.

D-3 — Priority and release target for the zh-TW defect (§4.1)

Why it can’t be derived technically. The fix is one line and technically uncontroversial; when it ships is a release-risk and market call. zh-TW is the product’s home market, and today an agent speaking Chinese is denied outright under credential_action: Block.

  • A — hotfix into v0.0.1-rc.7 ahead of everything in this Epic.
  • B — normal Phase 0 sequencing within the Epic.

Recommendation: A. It is a one-line change to a leaf crate, it breaks no conformance vector, and the current behavior is language-discriminatory in the product’s primary locale. Consequence of delay: zh-TW deployments cannot use blocking mode, and their audit data stays unusable for the shadow-mode comparison Phase 5 depends on.


11. Sources for third-party claims

The claims in §4.3–§4.5 and §5 about software we do not own are the ones a reviewer is least able to check and most likely to inherit into a ticket, so their sources are listed here rather than left implicit. Everything about our code is cited inline as path:line; everything measured is reproducible via §3.1.

ClaimSource
Presidio moved from microsoft/presidio to data-privacy-stack/presidio; docs at presidio.dataprivacystack.org; images on ghcr.io/data-privacy-stack/*the GitHub redirect from microsoft/presidio, and the project’s own docs site
Presidio is MITLICENSE in the upstream repository
Presidio supports en only by default; no Chinese or Taiwan recognizerpresidio-analyzer default_analyzer.yaml (supported_languages: [en]) and the predefined-recognizer list in its docs — also confirmed by direct measurement (§4.5: language: "zh" → HTTP 500)
Presidio ships SPDX SBOM + SLSA provenance as buildkit in-toto attestationsthe published image manifests on ghcr.io
Presidio’s upstream docker-compose.yml adds an ollama service that pulls models at runtimethat file in the upstream repository
Gitleaks is MIT; ships SLSA provenance but no SBOM; has no locale dimensionthe upstream repository’s LICENSE, release attestations, and rule schema
Gitleaks populates report.Finding.Secret with the raw secret unless --redact=100the report.Finding struct and the --redact flag handling in the upstream source
TruffleHog is AGPL-3.0 and its core feature is outbound credential verificationthe upstream repository’s LICENSE and its verification documentation
zh_core_web_sm ≈ 46 MB, NER F 68.42; zh_core_web_trf ≈ 396 MB, F 74.26; zh_core_web_lg wheel ≈ 603 MB; all trained on OntoNotes 5 (Simplified)spaCy’s published Chinese model cards
統一編號 checksum changed from mod-10 to mod-5 on 2023-04-01Ministry of Finance announcement of the unified business number rule change
2021 ARC format uses the same algorithm as the national ID, differing only in the first digitNational Immigration Agency’s published new-format ARC specification

Two caveats stated plainly: these were read during the Spike but are not pinned by digest or revision the way the measured artifacts in §3.1 are, so a reader checking them later may find upstream has moved. And the zh-TW model F-scores are the vendors’ own reported figures on Simplified-Chinese benchmarks — §4.5’s estimate of ~0.62–0.70 for Traditional Chinese is our extrapolation, not a measured result, and is labelled as such.


12. Traceability

ReferenceRelation
AAASM-5269this Spike
AAASM-5270parent Epic
AAASM-5174dispositioned in §2.7 — remains valid, split
ADR 0032the accepted decision
ADR 0015parent decision; invariants preserved
ADR 0018owns the verdict vocabulary; see §6.3 / D-2
ADR 0030constrains provider form; see §5.4
aa-security/benches/spike_5269_payload_classes.rsreproduces §3.2 (throughput)
aa-security/benches/spike_5269_percentiles.rsreproduces §3.2 (percentiles)
aa-security/benches/spike_5269_transport_floor.rsreproduces §3.3
scripts/research/aaasm-5269-presidio-probe.pyreproduces §3.4 and §4.5

Last updated: 2026-08-01 by Bryant Liu