Repository navigation
Known subjects: caller-declared identities, an egress check, receipts, report-only - #141
Conversation
…, report-only X-Cordon-Subject names the people a request is about. Their names, emails, phones, addresses, dates of birth, IDs and handles are matched in the forms people write them (name order, initials, handles, street-suffix abbreviations, any separators, accents, apostrophes and hyphens) and replaced with SUBJECT<n>_<KIND> placeholders ahead of the pattern catalog. The de-identified body is then read again, separator-blind and Unicode-normalised, and refused (422, upstream never called) if any identifier is still there. Media in a subject request is refused unless the policy and the header both allow it. The audit gains a digest of the caller's ref and the egress verdict, X-Cordon-Receipt returns the record's hash, and GET /admin/receipts?ref= serves a ref's records after verifying the chain. Tenant policy adds requireSubject, subjectMedia and reportOnly. Tests: a unit suite (69), an integration suite against the echo stub (35) and five fast-check properties: no identifier survives in any form or field, a disguised identifier slipped back in is caught, text without identifiers is never refused, and parsing and matching are total on byte soup.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching and receipt flow need changes before merge. The egress check can miss identifiers in ordinary metadata fields and phone numbers with longer separators, allowing both to reach the upstream with a verified verdict.
1. Blocking: src/subject.ts:389
MEDIA_PAYLOAD_KEYS.has(key) && (v.startsWith("data:") || node?.type === "base64" || key === "file_data" || key === "result" || (key === "data" && isMediaPart(node)));
The payload exemption applies outside media parts. For a Responses request with metadata: {result: "Marisol Quintero-Vance"} and that name declared as a subject, metadata is not redacted and leaves() skips its result value solely because of the key. countMedia() returns zero, so the request passes as verified and sends the identifier upstream. Ordinary string values starting with data: under these keys have the same problem.
Suggested fix:
Limit payload exemptions to actual binary fields within recognized media parts, carrying parent context where needed. Add an integration test with a declared identifier in metadata.result that must be refused before forwarding.
2. Blocking: src/subject.ts:142
const digitPattern = (d: string) => d.split("").join("[^\\p{L}\\p{N}]{0,3}");
With the subject phone 4045550187, a message containing "4 0 4 5 5 5 0 1 8 7" survives subject redaction because each gap exceeds three characters. The egress check reuses this same bounded pattern, and phone matchers have no folded needle, so it also returns no violation. This forwards the phone despite the documented separator-blind check.
Suggested fix:
Make the independent numeric egress check recognize longer separator runs while retaining the intended boundaries between unrelated numbers. Add a regression covering four or more spaces between every digit and assert that the upstream is never called.
rule:none
subjectMedia and the header's media now take block, mask or allow, and the stricter wins. In mask mode each image is read on the server (tesseract.js with the English model from node_modules, no network), the words of any subject identifier or catalog entity are painted over (sharp), and the masked image is read again; it is sent only when that reading finds nothing, after up to two rounds, otherwise the request is refused. PDFs, audio, files and images by URL or file id cannot be read here and are refused in mask mode. The three libraries are optional dependencies; without them mask mode refuses every image. Tests: a rendered screenshot of an invented person goes through mask mode and the image the stub receives reads back with no identifier and the rest still legible; a PDF and an image URL are refused; a header can tighten mask to block.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, media handling, receipts, policy checks and added tests were reviewed. Two egress-check gaps still let declared identifiers reach the upstream while the request is recorded as verified.
1. Blocking: src/subject.ts:400
MEDIA_PAYLOAD_KEYS.has(key) && (v.startsWith("data:") || node?.type === "base64" || key === "file_data" || key === "result" || (key === "data" && isMediaPart(node)));
The payload exemption applies to arbitrary objects, not just media. For a subject named Marisol Quintero-Vance, an Anthropic tool_use input such as {"result":"m a r i s o l q u i n t e r o v a n c e"} survives the regular matcher. countMedia returns zero, and leaves skips the result value solely because of its key. The separator-blind check therefore never sees the identifier, and the request is forwarded with a verified verdict.
Suggested fix:
Only exempt payloads within validated media structures, carrying parent context where needed. Treat result, file_data and data-URL-looking strings in ordinary tool inputs as text. Add an integration test that puts an obfuscated subject name under result and asserts refusal before any upstream call.
2. Blocking: src/subject.ts:151
const digitPattern = (d: string) => d.split("").join("[^\\p{L}\\p{N}]{0,3}");
The egress check is not separator-blind for numeric identifiers. With phone 4045550187 declared and redactSystem:false, a system prompt containing "404 555 0187" is left untouched by redaction. Both the phone matcher and digitRes reject the four-space gaps, and PHONE has no folded needle. The egress check returns no hit and sends the phone upstream, despite the documented protection for identifiers in unredacted system prompts.
Suggested fix:
Use a separate egress matcher that tolerates arbitrary runs of separators for numeric identifiers, without weakening the normal redaction matcher if its limit is intentional. Add a proxy test with four or more separators in an unredacted system prompt and require a 422 with no upstream call.
rule:none
…een digits A string under data, file_data, result, image_url or url was skipped anywhere in the body, so a declared name in Responses metadata.result left marked verified. Payloads are now skipped only below a recognised media part; the same key elsewhere is read like any text. Digits spread wider than three separators slipped past both detection and the egress check. Detection now spans up to eight separators, and the egress reading any run of non-letter, non-digit characters, so a phone or ID spaced out further is refused before the upstream is called while two numbers split by a word are still not joined. Regressions: metadata.result with a declared name is refused with the stub never called; four spaces between digits is redacted; twelve is refused with the stub never called.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject checks, media masking, receipts and added tests cover the main request paths, but the egress check still permits declared identifiers in numeric tool arguments and spaced-out abbreviated addresses. Both cases need regression tests before this can safely enforce subject protection.
1. Blocking: src/subject.ts:450
leaves(deidBody, texts);
The check reads serialized tool arguments as one string without interpreting their numeric literals. With phones: ["4045550187"] and a chat tool call whose function.arguments is '{"phone":4.045550187e9}', applyRedaction detects the expanded number but drops the SUBJECT1_PHONE span because numeric slots only retain NUMERIC_PII types. This check then searches the exponent spelling, which matches neither the phone regex nor its digit needle. The request is marked verified and forwards the declared phone unchanged in a valid numeric representation.
Suggested fix:
Check decoded tool-argument literals using the existing exactDecimal handling, and allow whole-value declared-subject matches to redact numeric slots. Add an integration test for exponent-form subject phones or IDs that asserts the identifier cannot reach upstream.
2. Blocking: src/subject.ts:234
return { patterns, folded: [alnum(street)] };
Only the supplied street spelling becomes a separator-blind needle. For addresses: ["1428 Elm Street"], an unredacted system prompt containing "1428 E l m S t" passes egress: the address regex requires contiguous Elm and a contiguous suffix, while the flattened text is 1428elmst rather than 1428elmstreet. The declared address reaches upstream with a verified receipt, despite the advertised abbreviation and separator handling.
Suggested fix:
Apply the separator-blind egress reading to the supported address variants as well as the original spelling. Add a test combining an abbreviated suffix with spacing inside the street name in a field redaction leaves untouched, and assert refusal before forwarding.
rule:none
…able matchers A declared phone sent as a JSON number in exponent form passed both checks: a numeric slot kept only checksum types, and the egress reading saw 4.045550187e9. A numeric leaf that is wholly a declared subject identifier is now redacted, and the egress check also reads each number literal in a string at its exact decimal value. Only the supplied street spelling was a separator-blind needle, so "1428 E l m S t" passed for "1428 Elm Street". Every spelling the address patterns accept (suffix and direction abbreviations) is now a needle. The egress check's test() left a global regex's lastIndex past a hit, so detection run later with the same matchers (the image re-read rounds) could start mid-text. lastIndex is reset before every matchAll and after every test. Regressions: exponent and decimal-point phones in tool arguments (unit and through the proxy, never reaching upstream), a spaced abbreviated address in an unredacted system prompt refused before forwarding, and detection after an egress hit.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, egress checks, media handling and receipt paths need changes before merging. There are paths that forward declared identifiers unchecked, and single-token common names bypass the intended common-word guard.
1. Blocking: src/proxy.ts:69
return onRedactionError(r, res, e, r.subject ? "closed" : failMode, t0);
The subject-specific fail-closed override only covers the detection catch. With TENANT_SECRET unset, a tenant configured through /admin/tenant with consistentPseudonyms: true and failMode: "open" reaches the earlier pseudonym-secret guard, which passes failMode to onRedactionError. A request carrying X-Cordon-Subject then forwards its original body upstream before subject compilation or the egress check.
Suggested fix:
Apply the subject fail-closed override to the pseudonym-secret guard as well, or enforce it centrally in onRedactionError. Add an integration test for a subject request to this tenant and assert that the upstream receives no call.
2. Blocking: src/subject.ts:446
const media = inMedia || isMediaPart(node);
Media classification depends only on an arbitrary object's type field, not its position in the provider schema. With subjectMedia: "allow", an Anthropic tool_use input such as {"type":"image","data":"m a r i s o l q u i n t e r o v a n c e"} is ordinary tool data, but this walk treats it as media and skips data. Detection does not match the letter-spaced name, so the request is marked verified and sends the declared identifier upstream. Allowing actual images should not exempt tool input strings from the egress check.
Suggested fix:
Restrict payload exemptions to actual media parts in provider content locations, rather than any object with a matching type. Add a regression test with a media-shaped tool input and a spaced-out identifier.
3. Blocking: src/subject.ts:203
patterns.add(tokens.map(tok).join(S)); // as given
For a declared single-token name such as "Will", this adds the unbounded case-insensitive pattern /Will/ before the common-word guard. It redacts "will" in "we will help" and even the substring in "willing". If the same text appears in an unredacted system prompt, the egress check refuses the request instead. The later COMMON check only controls the additional exact patterns and cannot prevent either result.
Suggested fix:
Handle single-token names separately: apply the common-word, minimum-length, capitalisation and boundary checks before adding any pattern or folded needle. Add tests declaring "Will" alone, rather than only "Will Grace", and cover both redaction and egress.
rule:none
Answers the review at #141 (review).
Fix at a6a91ef
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject checks cover the main request paths, but two unchecked representations can still forward declared identifiers: images inside content-source documents and large numeric identifiers outside redacted fields. Both need regression coverage before merging.
1. Blocking: src/subject.ts:423
else if (p?.type === "tool_result" || p?.type === "function_call_output") { parts(p.content); parts(p.output); }
A Messages document with source.type === "content" can contain image blocks in source.content, but this traversal never visits them. With such a document as the only media, countMedia returns zero, so both block and mask policies skip enforcement. Redaction leaves the image bytes intact, and egress scans their base64 spelling rather than the pixels. An image displaying a declared name can therefore go upstream unchanged with a verified receipt.
Suggested fix:
Traverse document content sources when locating media, and share those schema-aware locations with masking. Add integration cases for an image inside a content-source document under both block and mask policies, checking that it is refused or masked before forwarding.
2. Blocking: src/subject.ts:468
if (typeof node === "number" || typeof node === "bigint") {
out.push(String(node));
return;
}
Numeric leaves are not expanded to exact decimal form. For a declared ID "1000000000000000000000", a Responses body containing metadata.account: 1e21 leaves that field outside redaction. This branch reads it as "1e+21"; neither the ID matcher nor its digit needle matches, so egress reports verified and forwards the identifier. The exactDecimal handling above only applies to string leaves.
Suggested fix:
Apply exactDecimal to numeric leaves as well as number literals inside strings. Add a regression using an exponent-form numeric identifier in an unredacted body field and assert that egress refuses it before any upstream call.
rule:none
Answers the review at #141 (review).
Fix at 66593d8
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, egress checks, receipts and tests cover several important request paths, but media traversal still has two correctness gaps. Images in MCP tool results can bypass blocking and masking, while ordinary tool data can be mistaken for media during masking.
1. Blocking: src/subject.ts:424
else if (p?.type === "tool_result" || p?.type === "function_call_output") {
An Anthropic message containing an mcp_tool_result whose content contains a base64 image is not traversed here. With that image as the only media, countMedia returns zero, so both block and mask policies skip media enforcement. Text redaction leaves the image intact, and the egress scan reads its encoded bytes rather than its pixels. A screenshot of a declared person therefore reaches upstream with a verified verdict. The existing request text walker already recognises mcp_tool_result as a content container.
Suggested fix:
Traverse MCP tool result content when discovering media. Add integration cases with an image inside `mcp_tool_result` that prove block mode never calls upstream and mask mode processes or refuses the image.
2. Blocking: src/media/mask.ts:208
for (const v of Object.values(node)) mediaParts(v, out, depth + 1);
The masking walker recursively interprets every object as provider media, unlike countMedia's schema-aware traversal. For a mask-mode request containing a real image plus ordinary tool input such as { "type": "file", "name": "notes" }, countMedia triggers masking for the real image, but this walk also visits the tool input and returns a MediaRefusal for it. The request is rejected even though the tool object is ordinary data, not a file attachment. Image-shaped tool data can likewise be decoded and rewritten.
Suggested fix:
Share the schema-aware media discovery between counting, egress and masking, while retaining replacement access to actual image parts. Add a mask-mode test combining a real image with media-shaped tool input and verify that the tool data is neither refused as media nor rewritten.
rule:none
Answers the review at #141 (review).
Fix at 016296f
One earlier full run had a single failure in a Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, media discovery, receipts and proxy enforcement paths are covered by substantial tests, but the egress check still lets a declared date of birth through in an ordinary spaced numeric form. That gap needs a regression test before this can merge.
1. Blocking: src/subject.ts:268
return { patterns, digits: [
${y}${mm}${dd},${mm}${dd}${y}] };
For a subject with dates: ["1986-07-14"], send a request to a tenant with redactSystem: false and system: "DOB: 7 / 14 / 1986". The date regexes require each slash to touch its digits, so none matches. The separator-blind digit needles require 19860714 or 07141986, neither of which matches the unpadded 7141986. There are no folded needles for dates. The egress check therefore reports verified and forwards the date unchanged, despite accepting the equivalent unspaced 7/14/1986 as this subject's date.
Suggested fix:
Include the supported unpadded and short-year numeric date forms in the separator-blind egress matchers, or apply separator-tolerant matching to all supported numeric date forms. Add a unit check and a proxy regression using an unredacted system prompt, asserting 422 and no upstream call.
rule:none
Answers the review at #141 (review).
Fix at fe2d24a[1] spaced numeric date of birth passed the egress check (
Tests:
Files: Tests: |
Answers the review at #141 (review).
Fix at bbdf539
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject checks, media handling, receipts and tests cover many representations, but the date matcher still allows an accepted date identifier to bypass the egress check when its letters are spaced out.
1. Blocking: src/subject.ts:309
else return { patterns: [loose(date)], digits: [], egress: [] };
A header with dates: ["July 14, 1986"] is accepted, but this branch supplies only a literal matcher and no separator-blind egress matcher. In a Responses request with input: "hello" and metadata.note: "J u l y 1 4 1 9 8 6", redaction leaves metadata untouched. Neither the literal matcher nor its normalised readings match the spaced month, and the empty digit and egress lists leave no further check. The request is marked verified and the declared date reaches upstream.
Suggested fix:
Give fallback date literals a separator-blind egress matcher, or reject unsupported date declarations rather than accepting them with weaker protection. Add unit and proxy tests using a spelled-out date declaration and its spaced-out form in an unredacted field, asserting refusal before upstream is called.
rule:none
Answers the review at #141 (review).
Fix at ced33fb
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. Changes are needed because the egress check misses a declared phone number when its leading country code is attached and the remaining digits are widely spaced. The review covered subject matching, outbound checks, media handling, receipts, policy enforcement and the added tests.
1. Blocking: src/subject.ts:460
...digits.map((d) => new RegExp(`(?<!\\d)${egressDigitPattern(d)}(?!\\d)`, "u")),
Declare phones: ["404-555-0187"] and send a Responses request with metadata.note containing "1404" followed by each digit of "5550187" separated by twelve spaces. Metadata is not redacted. The detection regex cannot span twelve spaces, and this egress regex cannot start at the first 4 because it is preceded by the country-code digit 1. Phone matchers have no folded needle to catch it either, so the request is marked verified and the declared phone reaches upstream. This also affects numbers declared with +1, since compilation removes that prefix.
Suggested fix:
Give the phone-specific egress matcher the same optional leading country code as detection, while retaining unlimited separators and number boundaries. Add unit and proxy regression tests with an attached leading 1 and separator runs longer than detection accepts, asserting refusal before upstream is called.
rule:none
Answers the review at #141 (review).
Fix at d21f6d8
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, egress walk, media handling, receipts and added tests were reviewed. A short initial-and-surname form can still reach upstream unchanged while the egress check records it as verified.
1. Blocking: src/subject.ts:254
} else for (const p of egressSpread(h)) egress.add(p);
For a subject declared as "Will Li", send "W . L i" in message text. The initial-and-surname detector requires the surname's letters to be contiguous, so it leaves this text unchanged. Here the initial form is "WLi", but egressSpread defaults to a minimum of four characters and returns no pattern. "Will" is excluded as a common word, "Li" is below the single-token minimum, and the full-name needles do not match "wli". The egress check therefore returns no violation and forwards the identifier with a verified verdict.
Suggested fix:
Include word-bounded separator-blind patterns for supported initial-and-surname forms shorter than four characters, without changing the common-word guard for standalone names. Add unit and proxy regressions for "Will Li" written as "W . L i" that assert refusal and no upstream call.
rule:none
Answers the review at #141 (review).
Fix at 5e2c6c6
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, egress walk, media handling, receipts and added tests cover many request forms, but address matching still lets a declared street address through when the declaration includes an apartment without a comma.
1. Blocking: src/subject.ts:285
const street = addr.split(/,|\n/)[0].trim();
Declare addresses: ["1428 Elm Street Apt 4"] and send a Responses request with input: "hello" and metadata.note: "1428 Elm St". The street tokens retain "Apt 4", so every abbreviated pattern and folded spelling requires that unit too. The metadata is not redacted, and the egress check finds neither the full declaration nor a spelling containing the required unit. It marks the request verified and forwards the identifying street address. This also contradicts the nearby claim that unit numbers are covered by the street line.
Suggested fix:
Separate apartment/unit designators from the street portion even when no comma precedes them, and compile street-only forms alongside the complete address. Add a unit test and a proxy refusal test for a unit-bearing declaration with an unredacted street-only spelling.
rule:none
Answers the review at #141 (review).
Fix at 4624ab0
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, egress walk, masking, receipts and regression tests cover many text and content-part paths. Media discovery still misses Chat Completions assistant audio references, allowing subject requests to bypass both block and mask policies.
1. Blocking: src/subject.ts:551
for (const m of Array.isArray(body.messages) ? body.messages : []) parts(m?.content);
Chat Completions can replay an assistant audio response through a message such as {"role":"assistant","content":null,"audio":{"id":"audio_123"}}. This walk only inspects content, so that request has a media count of zero. If the referenced audio contains a declared person's identifiers, neither block nor mask runs, and the egress walk only sees the opaque audio ID. The upstream receives and resolves the audio reference despite the subject media policy.
Suggested fix:
Include message-level assistant audio references in schema-aware media discovery. Count them as media and refuse them in mask mode because their bytes cannot be read locally. Add integration cases for both block and mask that assert the upstream is not called.
rule:none
Answers the review at #141 (review).
Fix at 5a1f31e
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, egress walk, media handling, receipts and added tests were reviewed. Declared identifiers need normalisation before compilation: an accepted phone written with full-width digits currently receives no protection.
1. Blocking: src/subject.ts:408
let d = value.replace(/\D/g, "");
A header containing phones: ["4045550187"] passes validation, but this expression removes every digit. The length check then skips the identifier entirely. A request containing either "4045550187" or "4045550187" in unredacted Responses metadata consequently passes egress as verified and reaches upstream. Normalising outgoing text cannot help because no phone matcher was compiled.
Suggested fix:
Normalise declared phone values with NFKC before extracting digits, and apply the same principle to other identifier compilation paths that discard non-ASCII characters. Add unit and proxy tests with a full-width declared phone and both ASCII and full-width outgoing spellings in metadata, asserting refusal before upstream is called.
rule:none
Answers the review at #141 (review).
Fix at d825586
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject checks, media handling, receipts and regression tests cover many identifier forms, but the ID compiler still silently changes non-ASCII identifiers and can forward them unchanged with a verified verdict.
1. Blocking: src/subject.ts:440
const d = value.replace(/[^A-Za-z0-9]/g, "");
A valid declaration such as ids: ["ABé1234"] compiles as AB1234. If Responses metadata.note contains "ABé1234", redaction leaves that field alone. Neither the detection pattern nor the egress pattern matches the declared value, and the folded needle ab1234 is absent from the unaccented text abe1234. The request therefore receives egress=verified and sends the identifier upstream unchanged. NFKC does not remove this accented letter.
Suggested fix:
Preserve Unicode letters and numbers when compiling IDs, with consistent accent handling between declarations and outbound text, or explicitly reject unsupported ID characters rather than silently deleting them. Add unit and proxy tests for this declaration in an unredacted metadata field, asserting refusal before any upstream call.
rule:none
Answers the review at #141 (review).
Fix at d0b2757
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The subject matching, egress walk, media handling, receipts and regression tests cover many request forms, but the egress check still misses separator variants of accepted short addresses.
1. Blocking: src/subject.ts:429
egress = f.folded.filter((x) => x.length < 8).flatMap((x) => egressSpread(x));
For a declared address "1 A St", addressForms produces the abbreviated spelling "1ast" and the expanded spelling, so those are covered. However, an accepted address "1 A" produces only "1a": it is excluded from folded at line 426 and egressSpread drops it because its default minimum is four characters. A Responses request with input "hello" and metadata.note "1 / A" therefore passes unchanged: metadata is not redacted, neither address detection pattern accepts the slash, and there is no separator-blind egress matcher. The request is marked verified despite carrying the declared address with separators changed.
Suggested fix:
Compile the short-address egress forms with a minimum that covers every accepted address, or explicitly reject addresses that cannot receive that protection. Add a unit check and an integration test for this short-address case, asserting refusal before upstream forwarding.
rule:none
Answers the review at #141 (review).
Fix at f6d4738
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: approve. The subject matchers feed redaction and a separate outbound check, with refusals before forwarding and receipt fields included in the audit hash. The added tests cover identifier variants, provider request shapes, media handling, report-only forwarding and receipt lookup; the reference header schema needs a small correction.
Minor:
docs/reference.md:75: The header schema omits "mask", although parseSubjectHeader accepts it and the policy documentation below describes it. Readers following this schema cannot tell that the header can tighten an allow policy to mask.
Pattern detection finds what has a shape. It cannot know that "Marisol Quintero-Vance", "Quintero-Vance, M.", "mqvance77" and "1428 Elm St" are one person. A caller that already knows who a request is about can now say so.
What it adds
X-Cordon-Subject(base64url JSON): up to 8 subjects with names, emails, phones, addresses, dates of birth, IDs, handles and other literals, plus an opaqueref.How they're matched:
Matches become
<SUBJECT<n>_<KIND>_…>and win over the pattern catalog.Egress check. After redaction, every string and key in the outgoing body is read again, Unicode-normalised, without zero-width characters, case-folded and separator-blind. Any identifier still present refuses the request with 422 and the upstream is never called. The refusal names the kind, never the value.
Media: block, mask or allow (the stricter of the policy
subjectMediaand the headermediawins; the policy defaults to block). Mask reads each image on the server with an offline OCR engine (tesseract.js plus the English model from node_modules, no network), paints over the words of any subject identifier or catalog entity (sharp), then reads the masked image again. It is sent only if that second reading finds nothing, after up to two rounds; otherwise the request is refused. PDFs, audio, files, and images given by URL or file id are refused in mask mode. The three libraries are optional dependencies.Receipts.
X-Cordon-Receiptreturns the record's hash.GET /admin/receipts?ref=verifies the chain first, then returns the records for that ref.Policy.
requireSubject: requests without the header get a 400.subjectMedia.reportOnly: forward unchanged and record the verdict, for measuring a caller before enforcing.All three take part in the X-Tenant strictness rule.
Fail closed.
FAIL_MODEsays.offtenant is refused.Tests
_test_subject.mjs: 69 unit checks (header validation, every name, contact, address, date and ID form, the common-word guard, catalog coexistence, the egress check, media counting, the ref digest)._test_subject_proxy.mjs: 35 integration checks against the echo stub, covering:_test_fuzz_subject.js: five fast-check properties:Mask mode (in
_test_subject_proxy.mjs): a rendered screenshot of an invented person goes through mask mode. The image the stub receives reads back asName: ███ Email: ███ Phone: ███ ... Last login from Atlanta. PDFs and image URLs are refused, and a header can tighten mask to block.npm test: all suites green (262 + 70 + 18 fuzz + 5 subject fuzz + 156 + 42 + 94). The subject fuzz properties also pass at 2,000 runs each.