Read-only walk of both runtimes per AUDIT_TRAIL_PRD.md §8 phase 1.
Fills "UNKNOWN" cells in PRD §3 + §7 with file:line evidence.
Headline findings:
- candidates_safe + workers_safe views EXIST as a defense layer but
are BYPASSED — tool registry SQL templates query raw tables
- PII traverses 7+ persistence/transmission paths per fill scenario:
SQL → tool_result → LogEntry → /v1/respond → Langfuse → outcomes.jsonl
→ overseer_corrections.jsonl
- candidate_id is stable but co-located with PII in workers_500k.parquet
(no separate identity service)
- /audit/subject/{id} endpoint does not exist
- Append-only persistence is universal — RTBF requires crypto-erasure
- Pathway memory is structurally subject-agnostic in fingerprints
(defensive); trace bodies may leak PII (needs sampling)
- Go side mirrors Rust PII shape — parity in the leak too
- Worked example (John Martinez audit today): NOT POSSIBLE to produce
complete-and-defensible response
Recommends 4 cheap high-value moves before Phase 2 design starts:
defense-layer enforcement (rewrite 3 SQL templates to _safe views),
sample state.json/Langfuse to confirm pathway memory is clean, walk
Bun mcp-server tool surface, schema-audit for protected-attribute
proxies. None are commitments — J's call.
No code changes in this commit. Companion to AUDIT_TRAIL_PRD.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
Phase 1 Discovery — Subject + PII Surface Map
Status: Draft — 2026-05-03 · Drafted by: working session 2026-05-03 · Companion to: AUDIT_TRAIL_PRD.md
Purpose. Read-only walk of both runtimes. Fills in the "UNKNOWN" cells in
AUDIT_TRAIL_PRD.md§3 surface map and §7 current-state-vs-target table with file:line evidence. No code changes. Output: a complete picture of where subject identifiers + PII flow today, where they leak, and what an audit response could and could not produce right now.
TL;DR — what the walk found
-
A defense layer EXISTS but is BYPASSED. Two PII-masked views (
candidates_safe,workers_safe) live indata/_catalog/views/and correctly drop name/email/phone. No production tool query uses them. The MCP tool registry'ssearch_candidatesandget_candidateSQL templates query the rawcandidates/workers_500ktables and return full PII to the LLM context. Worth considering: the views are functioning policy artifacts that nobody routes through. -
PII flows freely through the LLM substrate. In a single fill scenario, candidate names + emails + phones traverse: SQL → tool_result → LogEntry → execution_loop log →
/v1/respondHTTP response → Langfuse trace input + output →data/_kb/outcomes.jsonl(fills with name) →data/_kb/overseer_corrections.jsonl(operation + correction text). At least 7 distinct persistence/transmission paths per scenario. -
candidate_idis a stable token but not an isolated one. It's a column on the sameworkers_500k.parquetthat holds name/email/phone — there is no separate identity service. Joining candidate_id back to PII is one DataFusion query away. The audit-trail PRD's §5 identity-service intent is not yet built; the architectural separation does not exist. -
No
/audit/subject/{id}endpoint exists. Reconstructing "every decision about person X" today requires manual cross-correlation across 4+ JSONL files + Langfuse + pathway memory + observer events. There is no canonical query path; the audit response cannot be produced in any reasonable time today. -
Go side mirrors the Rust pattern. Go validator's
rosterRowcarries Name; Go SessionRecord carriesPrompt(truncated to 4000 chars) which contains the natural-language operation including candidate names. Cross-runtime parity in the PII-leak too. -
Append-only persistence is universal.
outcomes.jsonl,overseer_corrections.jsonl,pathway_memory/state.json,sessions.jsonl, Langfuse — all append-only. Right-to-be-forgotten under the current architecture requires the cryptographic-erasure approach from PRD §6 because no hot data store supports per-subject deletion today. -
The matrix-indexer is currently NOT subject-aware.
pathway_memory::PathwayTracefingerprints are keyed bytask_class + file_prefix + signal_class— none of those are subject identifiers. A pathway carries information about CODE behavior, not about CANDIDATES. This is good for audit defensibility (matrix index can't drive discrimination) BUT means any future "matrix index learns about candidate X" feature would require careful design to not become a PII sink.
§1 — PII flow paths in the Rust stack (file:line evidence)
1A — MCP tools query raw tables (PII enters the LLM context)
| File:line | What | PII columns returned |
|---|---|---|
crates/gateway/src/tools/registry.rs:96 |
search_candidates SQL template |
first_name, last_name, phone, email, city, state |
crates/gateway/src/tools/registry.rs:107 |
get_candidate SQL template |
SELECT * — returns ALL columns including hourly_rate_usd, address, anything in the table |
crates/gateway/src/tools/registry.rs:129 |
top_recruiters SQL |
recruiter names (employee PII, not candidate but still PII) |
crates/gateway/src/tools/registry.rs:141 |
engaged_unplaced_candidates SQL |
c.candidate_id, c.first_name, c.last_name, c.phone, c.vertical |
mcp-server/index.ts:1722, 2173, 2269 |
address concatenation (street_number + street_direction + street_name) |
full street addresses surfaced to MCP UI clients |
Architectural gap: the MCP tool registry has no "view-only" mode. Adding _safe view routing would be one sql_template rewrite per tool. Today: every fill scenario invokes search_candidates → LLM sees full PII for every candidate in the result set.
1B — LLM tool results captured in execution log
| File:line | What |
|---|---|
crates/gateway/src/execution_loop/mod.rs:30-39 |
LogEntry struct: {turn, role, model, kind, content: Value, at}. content is the raw tool_result body (carries PII when kind=tool_result). |
crates/gateway/src/execution_loop/mod.rs:217 |
self.append(LogEntry::new(..., "tool_result", trimmed)) — every tool result appended to self.log |
crates/gateway/src/execution_loop/mod.rs:1319 |
per-render cap: kind == "tool_result" gets 1200 char cap (vs 200 for other kinds). Trimming is for token economy, not PII redaction — names/emails fit in 1200 chars. |
crates/gateway/src/execution_loop/mod.rs:30-50 |
Fill struct (in same file): {candidate_id: String, name: String, reason: Option<String>}. The name field is load-bearing per the PRD-defined FillProposal artifact contract. |
1C — Execution log returned to HTTP caller
| File:line | What |
|---|---|
crates/gateway/src/v1/respond.rs:111-119 |
RespondResponse { status, artifact, log: outcome.into_log(), iterations, error } — full log array returned in JSON response body to whoever called POST /v1/respond |
crates/gateway/src/execution_loop/mod.rs:82-93 |
RespondOutcome::{Ok,Failed,Blocked} all carry log: Vec<LogEntry> — no path drops the log on its way to the caller |
Audit implication: any HTTP client calling /v1/respond today receives full PII in the response. Authentication on /v1/respond is the only access control. Authorization is binary (either you can call it or you can't); there is no row-level filtering of which candidates you're allowed to see in the response.
1D — Persisted JSONL sinks on disk
| File:line | Path written | PII shape |
|---|---|---|
crates/gateway/src/execution_loop/mod.rs:508 |
data/_kb/outcomes.jsonl (append) |
{operation, fills: [{candidate_id, name, reason?}], error, ...} — observed live: operation carries natural-language fill request including role + city + state; fills carries the full Fill struct with name. |
crates/gateway/src/execution_loop/mod.rs:701 |
data/_kb/overseer_corrections.jsonl (append) |
{operation, correction, sig_hash, ...} — correction is the overseer's free-text guidance which often references specific candidates by name (e.g. "the executor picked Emily Garcia who is at fill capacity, try Maria Rodriguez instead") |
mcp-server/observer.ts:138-139 |
data/_observer/ops.jsonl (append) |
observer event log; PII content depends on what the operation context included. Empty on this box right now (file doesn't exist), but the writer is wired. |
mcp-server/observer.ts:161 |
data/_kb/observer_escalations.jsonl (append) |
sampled live: technical analysis fields (no PII observed in current rows), but analysis field is free-form LLM output and could include names depending on the escalation trigger. |
crates/gateway/src/v1/session_log.rs (writer) → /tmp/lakehouse-validator/sessions.jsonl |
per lakehouse.toml [gateway].session_log_path |
SessionRecord { ..., prompt, attempts: [{... raw}], artifact } — prompt carries the operation text (PII), raw carries each model attempt's raw text (PII when model reasoned about specific candidates), artifact carries the final FillProposal {candidate_id, name} shape on success. |
1E — Langfuse external persistence
| File:line | What gets sent |
|---|---|
crates/gateway/src/v1/langfuse_trace.rs:166 |
body.input.messages = ev.input — full message array (system + user + assistant + tool messages). Tool messages contain raw PII for tool_result entries. |
crates/gateway/src/v1/langfuse_trace.rs:191 |
body.output = ev.output — full model generation. When the model emits a Fill or reasons about candidates, names appear here. |
Langfuse runs at :3001 per memory, with credentials in /etc/lakehouse/langfuse.env. The Langfuse storage tier holds these traces. PII leaves the lakehouse infrastructure boundary at this point — Langfuse's storage layer (Postgres + ClickHouse, default config) holds it.
1F — Pathway memory
| File:line | What |
|---|---|
crates/vectord/src/pathway_memory.rs:169 |
PathwayTrace struct definition |
data/_pathway_memory/state.json |
Persisted state — need read to confirm whether real names appear in traces. |
Pathway traces are keyed by pathway_id = SHA256(task_class + file_prefix + signal_class) — none of which are subject identifiers. The fingerprint is structurally subject-agnostic. Whether the trace BODY (kb_chunks, observer_signals, reducer_summary, final_verdict) leaks PII depends on what those fields contain at write time. From the struct shape: reducer_summary and final_verdict are strings written from execution loop output — highly likely to leak PII when summarizing fill outcomes. Need to confirm against live state.json.
§2 — Defense layer that exists but is bypassed
data/_catalog/views/candidates_safe.json:
- Drops:
last_name, email, phone, hourly_rate_usd - Masks:
candidate_id(keep first 3, last 2 chars —CAND-...01shape) - Row filter:
status != 'blocked' - Visibility intent (per
description): "Visible to recruiter / mode-runner agents."
data/_catalog/views/workers_safe.json:
- Drops:
name, email, phone, zip, communications, resume_text - Reason given: "resume_text + communications carry verbatim PII (full names) and there's no in-view text scrubber, so they're dropped wholesale"
- Source for the rebuilt
workers_500k_v9vector corpus
The _safe views are the right policy artifact. The ARCHITECTURAL gap is at the SQL-template layer in crates/gateway/src/tools/registry.rs — the LLM-facing tools query the RAW tables, not the SAFE views. Three out of three candidate-touching tool templates (search_candidates, get_candidate, engaged_unplaced_candidates) bypass the safe view.
There is no enforcement preventing this. There is no test that asserts "tool SQL must reference only _safe tables." There is no warning logged when raw tables are queried via tool surface.
Quick fix shape (NOT a phase 1 deliverable, just noting the change shape): rewrite the tool sql_templates to FROM candidates_safe (or FROM workers_safe); add a build-time check that crates/gateway/src/tools/registry.rs only references *_safe tables; add a runtime gate in queryd that refuses LLM-attributed queries on raw tables. These would land in Phase 4 (subject tagging across substrates) or possibly Phase 1.5 (defense-layer enforcement) per the AUDIT_TRAIL_PRD.
§3 — Identity / candidate_id provenance
| Question | Finding |
|---|---|
Is candidate_id a stable token? |
Yes — observed format CAND-NNNNNN (e.g. CAND-000001). Stable across the table. Used as join key in the schema (per crates/ui/src/main.rs:160 cross-table join hints). |
| Is the candidate_id ↔ PII mapping in a separate service? | No. Both live in data/datasets/workers_500k.parquet. A SQL SELECT name, email FROM candidates WHERE candidate_id = '...' resolves the mapping in one query. |
| Is the mapping itself audited? | No. No log records "who looked up the PII for which candidate when." This is the identity-service gap from PRD §5. |
| Does anything ever generate a different token (UUID, opaque hash) instead of using candidate_id directly? | No — every tool, every validator, every persistence sink uses candidate_id as-is. |
Production-ready implication: the staffing client's lawyer asks "show me the access log for candidate X's PII" → we cannot produce one. Every access happens via SQL against workers_500k; no row-level access log exists.
§4 — Go-side parity with the leak surface
| Rust file:line | Equivalent Go file:line | Same shape? |
|---|---|---|
crates/validator/src/lib.rs (FillProposal carries {candidate_id, name}) |
internal/validator/fill.go + internal/validator/lookup_jsonl.go:23-31 (rosterRow carries CandidateID, Name, Status, City, State, Role, BlacklistedClients) |
Yes — same PII shape |
crates/gateway/src/v1/session_log.rs SessionRecord with prompt/artifact |
internal/validator/session_log.go SessionRecord with Prompt string, Attempts[].Raw string, Artifact map[string]any |
Yes — same shape |
outcomes.jsonl / overseer_corrections.jsonl writers |
Not present on Go side (Go validatord doesn't currently run the execution_loop with tool dispatch — Phase 4 of Go rewrite is what wires that) | Asymmetric — Go side writes less but only because feature parity isn't done |
| MCP tools registry | Not on Go side yet (mcp-server is still Bun) | Bun is the surface for tool dispatch in both runtimes today |
Cross-runtime audit implication: even though our 5 parity probes (validator/extract_json/session_log/materializer/embed) all pass 32/32, none of them assert PII handling. A new pii_parity.sh probe would feed identical PII-tagged input through both runtimes and assert identical redaction behavior. As of today, neither runtime does redaction at the substrate level, so the probe would just confirm "both leak identically."
§5 — Mapping back to AUDIT_TRAIL_PRD §3 surface map
Updated cells with file:line evidence:
| Decision happens at | Currently logged where | Audit-completeness gap (revised) |
|---|---|---|
| Ingestion (candidate added to pool) | data/datasets/workers_*.parquet rows; ingestd writes via crates/ingestd/; no per-subject "added at" event journal found |
GAP — no subject-tagged ingest event. When + who + how a candidate entered is not auditable per-subject. |
| Embedding creation | crates/aibridge/src/client.rs (post-sidecar-drop direct path); LRU cache crates/aibridge/src/cache.rs (commit 150cc3b); cached entries are keyed by (model, text) — the text is the cached body, which for candidate embeddings IS PII (candidate name + role + skills appear in source text per workers_500k_v9 build SQL). Cache itself is in-memory, not persisted, but text-as-key means PII is in process memory. |
MAJOR GAP — no per-embedding audit row. Cache key contains PII. No subject tagging means we cannot answer "what embedding was generated for candidate X." |
| Search inclusion | Tool result entries in execution log + Langfuse + outcomes.jsonl fills field |
Partial — fills are logged, but you have to grep across outcomes.jsonl + Langfuse for any mention of a candidate_id. No "all search results that included X" canonical view. |
| Search rank | Result set in chat traces (Langfuse), not indexed by candidate | Partial — Langfuse trace has the result set; no inversion (candidate → ranks-received). |
| Fill recommendation | outcomes.jsonl fills array with name + candidate_id |
Partial — present, but mixed with PII in the natural-language operation field. |
| Validation outcome | sessions.jsonl per [gateway].session_log_path config; per-attempt verdict_kind, error |
Partial — works per-session, not per-subject. To find "all validations that touched candidate X" you'd need to grep the JSONL by candidate_id. |
| Iterate retry escalations | Same sessions.jsonl attempts[] array |
Same as above |
| Observer signals | data/_observer/ops.jsonl (file not present today on this box, writer is wired) |
UNKNOWN content shape until the writer fires for a fill scenario — needs verification |
| Matrix-indexer compounding | data/_pathway_memory/state.json — fingerprinted by code, NOT by subject |
NO subject leak in fingerprint structure (good for audit defensibility). Trace bodies (reducer_summary, final_verdict) MAY carry PII — needs sampling to confirm. |
§6 — Mapping back to AUDIT_TRAIL_PRD §7 current-state-vs-target gap table
| Capability | Phase 1 finding | Status |
|---|---|---|
| candidate_id as canonical token | Stable CAND-NNNNNN format. Same column lives in same parquet as PII. |
Token exists; isolation does not. |
| Identity service | Doesn't exist. PII + candidate_id co-located in workers_500k.parquet. |
Real gap — needs new service. |
/audit/subject/{id} endpoint |
Doesn't exist. No audit route in crates/gateway/src/v1/mod.rs route listing. |
Real gap — needs new endpoint. |
| Subject-tagged embeddings | LRU cache (model, text) keys; text contains PII; no audit row per embed. |
Real gap. |
| Subject-tagged search results | Langfuse trace contains result set; not subject-indexed. | Real gap (queryability, not capture). |
| Subject-tagged validation outcomes | Yes — sessions.jsonl carries candidate_id (in the attempt's raw field) but not as a queryable top-level field. |
Partial — needs subject_id top-level promotion. |
| Subject-tagged matrix indexer entries | Pathway fingerprints are subject-agnostic by design. Trace bodies may leak. | Decision needed (PRD open question 7) — keep code-only OR risk PII surface. |
| Protected-attribute filter at decision time | Not enforced anywhere. SQL templates return whatever columns they SELECT; no protected-attribute removal at the gateway boundary. The candidates table schema in the demo SQL includes age (years_experience is a proxy), and the call_log/email_log tables likely contain free-text correspondence. |
Real gap — major. Requires both schema audit + boundary enforcement. |
| Retention policy | None enforced. Append-only files grow indefinitely. | Real gap. |
| Right to be forgotten | Not implementable today on append-only logs without cryptographic erasure. | Per PRD §6 design, real engineering needed. |
| Cross-runtime parity | 5 algorithm probes pass; 0 audit/PII probes exist. | Probe set needs extension. |
§7 — Worked example: John Martinez audit trail TODAY (negative result)
If John Martinez (candidate_id CAND-042195) requests an audit:
-
Find his candidate_id. Manual SQL:
SELECT candidate_id FROM candidates WHERE first_name='John' AND last_name='Martinez'— returns 1+ rows. Already a leak: someone with SQL access can correlate name → candidate_id ad-hoc. -
Find every fill scenario that included him.
grep "CAND-042195" data/_kb/outcomes.jsonl— returns rows where his fill was included. But the row's natural-languageoperationfield may NOT contain his candidate_id (it's the fill request, e.g. "fill: Welder x2 in Toledo, OH"), so we'd miss scenarios where he was a candidate but not a fill. To find those, we'd need to grep Langfuse traces (off-box) or the per-tool result content in execution logs (which aren't persisted as separate JSONL — they live in the HTTP response that already left the building). -
Find every validation that touched him. Grep
/tmp/lakehouse-validator/sessions.jsonlforCAND-042195— would catch FillValidator phantom-ID rejections AND successful fills, but the candidate_id would appear in either the prompt or the raw attempt text, not as a top-level queryable field. -
Find every embedding generated for him. Cache is in-memory; nothing persisted. Cannot answer.
-
Find every search result that ranked him. Off-box in Langfuse. Untrieable in lakehouse without a separate Langfuse query pipeline (which doesn't exist).
-
Find pathway memory traces involving him. Pathway fingerprints don't carry his ID. The
reducer_summarystrings might mention him (need to grep state.json) but the fingerprint search wouldn't surface them. -
Show what protected attributes were exposed to the model. No record of input_features per decision — the LLM saw whatever the SQL returned, and we have no per-decision input_features audit row.
-
Format the output for legal. Even if we collected all the above, there's no signing, no integrity hash, no schema, no template.
Estimated time to produce a complete-and-defensible response today: not possible. Estimated time to produce an INCOMPLETE response by cobbling JSONLs + Langfuse exports: 2-5 hours per request, manual, error-prone, and the response would over-share (other candidates appearing in the same fill scenarios) AND under-share (embedding events, search rankings, pathway traces missed).
This is the Phase 1 result the AUDIT_TRAIL_PRD predicted: today's substrate is not production-ready for a discrimination-defense audit response.
§8 — What this discovery DID NOT cover
Phase 1 was scoped to file:line evidence + sampling of live JSONL state. The following deserve their own subsequent walks before phase 2+ design:
- Live sample of
data/_pathway_memory/state.json— confirm whetherreducer_summary/final_verdictstrings actually leak PII or stay generic. Read 3-5 traces and grep for names fromworkers_500k. - Live sample of Langfuse traces — confirm input message PII for a real fill scenario from past 7 days. Use Langfuse
:3001query API. - observerd event content — the writer is wired but
data/_observer/ops.jsonldoesn't exist on this box. Trigger a fill scenario and inspect the resulting event. - Bun mcp-server tool dispatch — does the Bun server log tool calls anywhere?
mcp-server/index.tsis 2900+ lines; partial walk only here. - bot/propose.ts — the bot proposal flow likely touches candidates; not walked in this pass.
crates/journaldmutation log — designed for row-level mutations per ADR-012; haven't confirmed whether candidate-table mutations land here with PII.- Go side observerd + chatd PII surface — the Go cmd/* binaries likely have analogous logging; confirmed validator parity but didn't walk observer/chat logging.
- Process memory + crash dumps — if the gateway dumps core, what PII is in it? Out of scope for code walk; comes up in security audit.
- Operator runbook — who has access to logs/, /tmp/lakehouse-validator/, MinIO buckets, Langfuse Postgres? Out of scope for code walk; comes up in operational security review.
These are listed so the next phase doesn't accidentally re-walk what's done OR skip what wasn't covered.
§9 — Recommended next moves (not commitments)
Per AUDIT_TRAIL_PRD §8, Phase 2 is the identity service design doc. Before that doc gets written, the cheapest high-value moves discovered here:
-
Defense-layer enforcement (1-2 hours work). Rewrite the 3 tool SQL templates in
crates/gateway/src/tools/registry.rsto use_safeviews. Add a unit test that asserts nocrates/gateway/src/tools/registry.rstemplate referencesFROM candidatesorFROM workers_500k(only*_safe). This is one commit. It prevents the most-trafficked PII leak path TODAY without waiting on the identity service. Cost: the LLM sees masked candidate_ids (CAN...95instead ofCAND-042195); some downstream tools (validator existence check) would need a "resolve to full ID" path that goes through the identity service — but that's exactly the architectural shape PRD §5 wants anyway. -
Sample state.json + Langfuse before phase 2 design. §8.1 + §8.2 above. ~30 minutes. Either confirms or refutes the "matrix indexer is subject-clean" finding from §1F.
-
Document the Bun mcp-server tool surface. §8.4. The Bun layer is a major PII transit point not fully covered here.
-
Identify whether protected attributes (age proxies, photo features, zip-code → race correlations) are currently in any tool-returned column. Schema-level audit of
candidates+workers_500k. ~1 hour. Might surface that some "neutral" columns are actually protected-attribute proxies.
These four moves give the phase-2 design doc strong evidence to lean on. None are commitments — J's call on what to do next.
Change log
- 2026-05-03 — Phase 1 discovery walk complete. Findings cited above with file:line references. No code changes. Companion to
AUDIT_TRAIL_PRD.md.