AD-020: Content Redaction
Summary
Redaction replaces sensitive spans — people, unreleased product names, internal roles, secrets — with protected placeholders before content is sent to AI translation or to an external translator, and restores the originals afterwards. The defining property is locality: the original value never leaves the machine. Detection runs offline by default, the replacement carries only a coarse category, and the original↔placeholder mapping lives in a local vault that is never serialized into the content handed to a tool, a prompt, or an exchange file.
The capability is framework-native — a peer of pseudo-translation and
search/replace — and lives under core/redaction/, two pipeline tools
(redact / unredact), a built-in secure-translate flow, and kapi extract
/ kapi merge integration.
Context
Creators increasingly translate with cloud LLMs and external CAT tools, but some source content must not be disclosed to either: pre-announcement product names, named individuals, confidential roles. The requirement is two-sided — the sensitive text must be absent from anything that leaves the machine, and the finished translation must read naturally with the originals back in place.
A naive find/replace cannot meet this: it either leaks (the mapping travels
with the document) or loses the ability to restore (no record of what was
replaced). The content model already has the right primitive — the
PlaceholderRun inline code, whose documentation names redaction as a use case
(AD-002) — and the streaming pipeline already preserves inline codes across
translation (AD-004, AD-011). Redaction builds on both.
Decision
Placeholder model
A redacted span becomes a model.PlaceholderRun with Type of the form
redaction:<category> (e.g. redaction:person), mirroring the entity:
prefix convention of the semantic vocabulary (AD-002). The run carries a stable
ID (the token), a visible stand-in string in Equiv/Data/Disp (default
template [REDACTED:{category}]), and constraints that mark it
non-deletable and non-cloneable. Categories are free-form strings; a
recommended set is surfaced in defaults and documentation.
The original text is not present on the run in any field.
The locality guarantee
The original value lives only in a vault, never in the content. Two backings:
- An in-process
SecretAnnotationon the block for single-run flows. It is keyed under a name no format writer serializes, andunredactdeletes it after restoring, so it cannot reach an output file. - A gitignored JSON sidecar (
.kapi/cache/redaction/<batch-id>.json, written0600) for the extract → external-translation → merge roundtrip.
For AI translation the guarantee is structural, not advisory: a block with
inline codes is sent to the model through RunsPlaceholderText, which renders
each placeholder as an opaque <x id="…"/> token — the model sees neither the
original nor even the visible label. ParseRunsPlaceholderText matches the
token back to the source run by ID on return, so the placeholder survives the
roundtrip with full fidelity.
Detection
Detection produces Match spans (byte offsets + category) consumed by
redaction.Redact. Backends:
-
Rules (default, fully offline): literal terms and regular expressions from a dedicated rules file, compiled by
RuleDetector. Deterministic and the only backend that preserves the locality guarantee without qualification. -
Entities (opt-in): the
redacttool readsmodel.EntityAnnotations already on the block — produced upstream by theai-entity-extracttool — and redacts the configured entity categories. The detection model is the caller's choice; a local model keeps everything on the machine, a cloud model trades that for coverage during the detection step only. Because an annotator can precede the redactor in the flow,ai-entity-extractandredactsit in the same flow (see AD-006).The categories are the option surface a user picks — "redact people", "redact dates", … — via
redact'sentityTypes(person, org, product, location, date, time, currency, measurement, role, other; aliases and the modelentity:prefix normalize, validated againstredaction.EntityCategories). Naming any category enables entity detection, so the user doesn't also list theentitiesdetector. Dates/times/currencies/measurements are excluded from the defaults (they usually need locale formatting, not hiding) but are opt-in.Conditional requirement, not a new schema language. Two distinct "requirements" are in play and neither needs a config-condition DSL: the resource requirement (NER ⇒ an LLM credential) lives statically on
ai-entity-extract;redactcalls no provider and declares noRequires, so enabling a category adds no resource requirement to redact — you add the NER tool to the flow (composition). The input requirement — redact needs an entity overlay when entity detection is on — is a config-derived IO contract:tools.ResolveRedactContract(registered viaToolRegistry.SetContractResolver) flips redact'sentityconsumed port from optional to required when its config enables entities, so a flow that redacts entities with no upstream producer failsValidateDataFlowinstead of silently leaving the content unredacted (and leaking it downstream). With only rule-based detection redact reads no upstream port and the contract is unchanged.
redact is a transformer (AD-006): it produces an edit plan — the
span→replacement edits plus the originals to vault — and the framework applier is
what rewrites the source. Redaction is a structured edit (a known span→replacement
map), so the applier rebases the surviving run-anchored source overlays onto
the redacted runs in one pass: a term tag from an upstream annotator follows the
rewrite and still reaches downstream steps, while a span overlapping a redacted
span (including the consumed entity spans) is dropped. The applier vaults the
originals as it replaces, so source rewrite and secret capture are atomic.
Restoration
unredact (and kapi merge) restore through two complementary paths, because
formats differ in whether they preserve inline structure on write:
- By placeholder ID — for structure-preserving carriers: in-process
pipelines, and XLIFF, where the placeholder is a real
<ph>/inline element. - By visible token text — for carriers that flatten the placeholder to its string on write (JSON, and XLIFF for inline types it does not model). The visible token is made unique within a block so the match is unambiguous.
CLI and recipe surface
kapi run secure-translate -i <file> --target-lang <l>— the in-process flowreader → redact → ai-translate → unredact → writer.kapi run redact-pii -i <file>— the built-in NER flow:ai-entity-extract(detect entities) →redact(configured for person/org/location/date). The placement pass (AD-006) keepsredactahead of any remote-egress step. Equivalent recipe:steps:- tool: ai-entity-extract- tool: redactconfig:detectors: [entities]entityTypes: [person, org, location, date]kapi extract --redact(or--redact-rules <path>) — emits a redacted bilingual file and writes the vault sidecar for the batch.kapi merge— restores originals from the batch sidecar after applying the translator's target;--no-restorekeeps the placeholders.
The recipe declares redaction under defaults.redaction (and per content
item), pointing at a separate rules file so the sensitive term list stays out
of the committed recipe:
defaults:
redaction:
enabled: true
rules: .kapi/redaction.yaml # gitignorable
detectors: [rules] # opt in: entities
placeholder: "[REDACTED:{category}]"
On kapi extract, redaction runs before TM pre-fill, so the translation
memory is queried with — and pre-fills targets from — redacted text; no
sensitive value reaches the emitted file by way of a TM match. On kapi merge,
the incoming source is always restored (so per-block staleness compares
original-to-original against the re-read source file); the target is restored
unless --no-restore is set.
Relationships to other ADs
- AD-002 (Content Model) — redaction is expressed entirely as
PlaceholderRuns;redaction:<category>extends the semantic vocabulary. - AD-004 (Processing Engine) —
redact/unredactare ordinary pipeline tools; the in-process vault rides the block through the stream. - AD-006 (Tool System) — both tools register with schemas and config
factories like any other;
secure-translateis a built-in flow. - AD-008 (Project Model) —
Redactionis a first-classDefaults/ContentItemfield; the sidecar lives under the regenerable cache. - AD-011 (AI Providers) — the
RunsPlaceholderTextplaceholder protocol is what keeps the original out of the prompt;ai-entity-extractfeeds the optional entities detector. - AD-017 (Bilingual Format Interop) —
--redacton extract and restore on merge slot into the existing bilingual roundtrip without changing its keys.
Rationale
Why a PlaceholderRun, not text substitution? Inline codes are already
protected from translation, survive the streaming pipeline, and round-trip
through XLIFF as <ph>. Reusing them means the model and CAT tools treat a
redaction exactly as they treat any other do-not-touch token.
Why is the original never on the run? So the guarantee is auditable: any serialized artifact can be scanned for the secret and must not contain it. The run carries only a category and a token.
Why dual restoration? ID-based restore is exact but needs the inline structure to survive. Plain-text carriers drop it, so a vault-backed text match on a per-block-unique token is the fallback. Together they cover every supported carrier.
Why rules by default, AI opt-in? Rule-based detection is deterministic and fully offline — it cannot itself leak. AI detection is more capable but, with a cloud model, discloses source during detection; making it opt-in keeps the default trustworthy.
Why a separate rules file? The term list is itself sensitive. Keeping it out of the committed recipe lets it be gitignored while the recipe still records that redaction is enabled.