# Spark Control — `/scrub` + `/rehydrate` endpoints (handover prompt)

*Hand this to the Spark Control developer to build the gateway redaction endpoints.
**OPTIONAL for Phase 1** — the Architect's grounding boundary already ships in our app
repo (the `backend/redaction/` module, deterministic scrub + golden-file leak tests).
Build this when we move to **multi-agent** enforcement (Phase 2 Analyst, Phase 3 Closer),
where we want ONE bypass-proof point with the pseudonym map living next to the local
models. Our in-repo module is the **reference implementation**; the contract below is what
our `SCRUB_BACKEND=gateway` client already calls, so aim for behavioral parity and we cut
agents over with zero app changes. Related: `docs/redaction-rehydration.md`.*

---

## Purpose

Add two gateway endpoints, `POST /scrub` and `POST /rehydrate`, that let any agent send
LP-specific context to the Claude API without exposing sovereign data. `/scrub`
de-identifies an agent's assembled context; the agent sends the de-identified text to
Claude; `/rehydrate` puts the real values back into Claude's response for human review.
The **pseudonym map** that links tokens to real values is the de-anonymization key — it
**must stay on the Spark, next to the local models, and never be sent or logged to any
third party.** This sits behind the same trusted Spark Control URL, TLS, and access
control that already front `/v1/chat/completions`, `/v1/embeddings`, `/v1/rerank`, and
`/api/search`. Spark Control does **not** call Claude here — it is the scrub and rehydrate
transform pair plus a server-held map.

## The three-tier classification (the rules `/scrub` enforces)

Classify every span into exactly one tier:

- **TIER-1 NEVER-SEND** — excluded entirely, not even tokenized: full LP list/export or
  bulk relationship graph, raw account/wire/routing numbers, SSN/passport/gov-ID, anything
  under a confidentiality obligation. Default: excise the span. `tier1_action="reject"`
  makes the whole call fail-closed (422) if any Tier-1 is detected.
- **TIER-2 TOKENIZE** — stable placeholders, swapped back locally after Claude: person
  names, org/fund names, emails, phones, addresses, exact $ amounts, identity-pinning
  dates. Placeholders are `[TYPE_N]`: `[PERSON_1] [ORG_1] [FUND_1] [EMAIL_1] [PHONE_1]
  [ADDR_1] [AMOUNT_1] [DATE_1] [LOC_1] [MISC_1]`. `N` is 1-based and **stable within a
  task**: the same real entity maps to the same token across every item in the call and
  across later `/scrub` calls reusing the same `map_handle`, so Claude can reason about
  relationships (`[PERSON_1] introduced [PERSON_2] to [FUND_1]`).
- **SEND-AS-IS** — passed through untouched: the substance Claude needs (objections,
  sentiment, generic deal mechanics, the drafted message body minus identifiers).

## The round-trip

`SCRUB` (your endpoint) → `REASON` (the agent calls Claude with placeholders only — your
gateway does NOT call Claude) → `RE-HYDRATE` (your endpoint) → human review (in our app).

## Request / response contracts

### `POST /scrub`
```json
{
  "task_id": "string, required, caller-chosen, stable across the round-trip",
  "actor": "string, agent name e.g. 'analyst' | 'closer' (for logging)",
  "items": [ {"id": "ctx_1", "text": "..."} ],
  "known_entities": {                         // CALLER-SUPPLIED dictionary (see "Dictionary" below)
    "persons": ["..."], "orgs": ["..."], "funds": ["..."], "emails": ["..."]
  },
  "tier1_action": "drop | reject",            // default "drop"
  "bucket": {"amounts": false, "dates": false}, // default FALSE for grounding: tokenize reversibly (no magnitudes to Claude). bucket=true only when a caller genuinely needs coarse magnitude
  "ner": "auto | rules_only | qwen",          // default "auto"
  "map_handle": "string, optional, reuse/extend an existing task map"
}
```
Response `200`:
```json
{
  "task_id": "...",
  "map_handle": "opaque server key to the map (NOT the map itself)",
  "items": [ {"id":"ctx_1","scrubbed_text":"...","tokens_used":["PERSON_1","AMOUNT_1"]} ],
  "stats": {"tier1_dropped": 2, "tier2_tokenized": 14, "distinct_entities": 9,
            "descriptive_flags": [{"item":"ctx_1","span":"the family that sold the mining company in Texas","action":"redacted"}]},
  "expires_at": "ISO-8601 map TTL (short-lived, e.g. 2h)"
}
```
`422 {"error":"tier1_detected","spans":[...]}` when `tier1_action="reject"` and Tier-1 found.
`400` on malformed input.

### `POST /rehydrate`
```json
{
  "task_id": "string, required",
  "map_handle": "string, required, from /scrub",
  "items": [ {"id":"out_1","text":"...with [PERSON_1] tokens..."} ],
  "actor": "string",
  "strict": true   // default true
}
```
Response `200`:
```json
{ "items": [ {"id":"out_1","rehydrated_text":"...real values..."} ],
  "stats": {"tokens_substituted": 6, "unknown_tokens": []} }
```
`409 {"error":"unknown_tokens","tokens":["PERSON_9"]}` when `strict` and the text contains a
token with no map entry — this is your tripwire for a Claude-hallucinated/smuggled token; do
NOT silently pass it through. `410 {"error":"map_expired"}` if the map TTL lapsed.

## Dictionary: CALLER-SUPPLIED (Option 1 — decided)

The known-entity dictionary is supplied by the **caller** in each `/scrub` request
(`known_entities`), NOT read by Spark Control from the CRM. We chose this over giving the
gateway CRM access because it keeps Spark Control **generic and portable** (no coupling to
Ten31's schema, so the package still works for another dual-Spark user), needs **no CRM
credentials on the gateway** (least privilege — the LP list lives in one place), stays
**fresh** (built live from the CRM each call), and matches the reference `scrub()` signature.
Our app builds it with its `build_known_entities` (names + name-parts + email local-parts)
and sends it scoped to the request. Treat it as **sensitive** (a slice of the LP list): hold
it only transiently with the map, never log or forward it. Tokens are per-task (stable within
a `task_id`/`map_handle`), which is also the lower re-identification-risk choice. If per-call
payload ever becomes a perf issue, an optional gateway-side cached export (Option 3) is the
escape hatch — but do not build that coupling now.

## Local-Qwen NER (how to find Tier-2 entities), cheapest/most-authoritative first

1. **The caller-supplied `known_entities`.** Tokenize every supplied person/org/fund/email
   surface deterministically (case- and unicode-fold insensitive, longest-match-first, with
   hyphenated-surname extension). This is the bulk of the work and needs no model. (This is
   the deterministic FLOOR — load-bearing but NOT complete; the NER pass below is required.)
2. **Rules** for structured PII: regexes for emails, phones, $ amounts, dates, and
   SSN/account-number shapes (account/SSN shapes route to Tier-1).
3. **Local-Qwen NER pass** for residual named entities the map and rules miss (a person/org
   in free text not yet in the CRM). Call the SAME local Qwen you already serve at
   `/v1/chat/completions` (enable_thinking=false, temperature 0), with a strict JSON-only
   extraction prompt returning `{entities:[{text,type,tier}]}`. Never a remote model — the
   input is exactly the sensitive text we are keeping local. `ner="rules_only"` skips this;
   `ner="qwen"` forces it; `auto` runs it only on spans unresolved by steps 1–2. Qwen should
   also flag **descriptive** re-identifiers (e.g. "the family that sold the mining company in
   Texas"), not just named entities.

## Map-stays-local (non-negotiable)

The pseudonym map `{token -> real_value}` is stored ONLY on the Spark, keyed by
`map_handle`, in memory or a short-lived local store, TTL-expired (default ~2h). Never
returned in full, never written to any log/metric/trace that leaves the box, never in any
Claude-bound payload. `/scrub` returns only the opaque `map_handle` and counts. If you add
`GET /scrub/map/{map_handle}` for same-box debugging, gate it behind the same auth and keep
it off by default.

## Logging

One row per call to our `interaction_log` (we give you a write path or ingest an emitted
event): `action='redaction.scrub' | 'redaction.rehydrate'`, `actor_type='agent'`,
`actor_id=<actor>`, `source='spark_control'`, `payload` = **COUNTS ONLY** (tiers dropped,
tokens by type, distinct_entities, descriptive_flags spans WITHOUT real values, model used).
The payload MUST NOT contain any real Tier-2 value, any Tier-1 content, or the map.

## Acceptance tests (must pass before agents route through it)

1. **Golden-file diff** — fixed inputs, recorded expected scrubbed output; assert NO Tier-1
   string and NO real Tier-2 identifier appears in any `/scrub` response or Claude-bound
   payload. (We will share our `backend/redaction/` fixtures + golden files.)
2. **Round-trip identity** — scrub → echo tokens → rehydrate reproduces the original real
   values exactly; token stability holds across items and across a second `/scrub` reusing
   the same `map_handle`.
3. **Re-identification spot-check** — feed ONLY the de-identified prompt to the local Qwen
   and ask it to name the real people/orgs/amounts; anything it recovers (esp. descriptive
   re-identifiers and amount+date+sector inference) is flagged and must be driven to zero or
   escalated to Tier-1/bucketing.
4. **Map-leak assertion** — scan every response body, log row, and Claude-bound payload in
   the suite; assert the map and all real values are absent.
5. **Strict-rehydrate tripwire** — a rehydrate input with an unmapped token returns 409.
6. **Fail-closed** — `tier1_action="reject"` returns 422 on any Tier-1 input, emits nothing.

## Notes

- This does NOT replace **minimization**: the agent must first ask "does Claude need this
  record content at all?" — often a local retrieval summary suffices. These endpoints are
  for when the answer is genuinely yes.
- Keep placeholders **cache-stable** (deterministic token assignment per task_id/map_handle)
  so they compose with prompt caching on the Claude side.
- We will hand you our in-repo `redaction` module + golden files as the reference; aim for
  behavioral parity first, then we cut agents over to the gateway as the single enforcement
  point.
