Appearance
Exact Data Match, claim by claim
WIN-DLP-EPIC-010 asks for privacy-preserving exact data match over approved structured records, because pattern detection cannot tell a customer's account number from any other number, and EDM can.
Every part of it is built and tested. The parts are not connected: no dataset has ever reached an endpoint. That is the finding, and it is the kind that hides behind a full green test run, because both ends are thoroughly covered and nothing tests the join.
Implementation: packages/crypto/src/edm.rs for the tags, packages/detector-engine/src/edm_runtime.rs for matching, apps/management-api/src/domain/edm.rs for the server.
1. Privacy-preserving - the property the feature is named for
| Claim | Proved by |
|---|---|
| The dataset is not recoverable from the index | edm_runtime::tests::the_dataset_itself_is_not_recoverable_from_the_index |
| A different key matches nothing | edm_runtime::tests::a_different_key_matches_nothing |
| Fingerprints are stable and key-dependent | edm::tests::fingerprints_are_stable_and_key_dependent |
| The field name is bound into the tag | edm::tests::field_name_is_bound_into_the_tag |
| A separator prevents field-value smuggling | edm::tests::separator_prevents_field_value_smuggling |
| Truncation is clamped to a safe range | edm::tests::truncation_is_clamped_to_a_safe_range |
| Collision probability grows with dataset size, and is stated | edm::tests::collision_probability_grows_with_dataset_size |
Two of those are about attacks rather than about correctness. Binding the field name into the tag stops a value in one column matching a rule about another; the separator stops ["ab","c"] and ["a","bc"] producing the same record tag, which is how a concatenation becomes a forgery.
SPIKE-005 measured the collision rate rather than calculating it, and docs/DETECTOR_CATALOG.md carries the number.
2. Matching
| Claim | Proved by |
|---|---|
| A full record in a document is matched | edm_runtime::tests::full_record_in_a_document_is_matched |
| A single field alone is not a record | edm_runtime::tests::a_single_field_alone_is_not_a_record |
| Fields far apart do not form a record | edm_runtime::tests::fields_far_apart_do_not_form_a_record |
| A partial record does not match | edm::tests::partial_record_does_not_match |
| Spacing variation does not defeat matching | edm_runtime::tests::spacing_variation_does_not_defeat_matching |
| Normalisation is locale-robust | edm::tests::normalization_is_locale_robust |
| Two separated records are both found | edm::tests::two_separated_records_are_both_found |
| A value not in the dataset does not match | edm_runtime::tests::a_value_not_in_the_dataset_does_not_match |
| Empty and punctuation-only values produce no tag | edm::tests::empty_and_punctuation_only_values_produce_no_tag |
| The hit cap is honoured, and the time budget with it | edm_runtime::tests::hit_cap_is_honoured; packages/detector-engine/tests/detector_budget.rs (DLP-257) |
The proximity rules are the interesting pair. A single field alone is not a record and fields far apart do not form a record are what stop a document that happens to contain one customer's surname from being an exact match, which is the difference between EDM and a very expensive keyword list.
3. The server side
26 tests in domain::edm. Import, versioning, listing, deletion, an audit entry written inside the same transaction as the change, a per-field quality judgement that refuses a column too weak to fingerprint, and a size projection that refuses an import larger than an endpoint could hold (DLP-240, tests/edm_budget.sh).
DLP-241 packs the tags as bytes rather than hex, and DLP-242 prices a set per field. Both were measured, not estimated.
4. The join that does not exist
PolicyBundlePayload::edm_sets is in the contract and carries exactly the right thing - per-field keyed hashes, never source values.
Every bundle is built with edm_sets: vec![]. One line in apps/management-api/src/domain/policies.rs, three lines above the one that does fill fingerprint_corpora from fingerprint_corpus::for_bundle.
And with_edm_indices - the engine's way in - is called from tests and examples only. Nothing in agent/core mentions EDM at all.
So an EDM detector in a published policy is reported by the endpoint as exact data match index '<id>' is not loaded, and not evaluated.
What saves it from being a silent failure is that last sentence: the engine reports not_evaluated rather than returning no match, so nothing is called clean on the strength of a dataset that was never there. The capability-clamping discipline holds even here. The feature still does not work.
The blocker is the key, not the wiring. The bundle field's own documentation says the HMAC key "is delivered separately over the mTLS channel" - a mechanism that does not exist: mTLS defaults to Off, no endpoint serves a key, and the agent has nowhere to keep one. Filling edm_sets alone would ship an endpoint holding fingerprints it cannot compute comparisons against.
Filed as DLP-267, with the design questions rather than a patch: where the salt lives on the endpoint (the device keystore already wraps a secret of this class and a second mechanism should not be invented), how it gets there (bundle, check-in, or gated on mTLS Required), and whether a bundle should carry every dataset or only those its own policies reference - the latter, almost certainly.
Document fingerprinting solved the identical shape in DLP-244 and is the worked example to follow.
5. What is not covered
- No end-to-end test, because there is no end to end. The nearest thing is
engine::tests::exact_data_match_finds_a_customer_record_end_to_end, which is end to end within the engine - it builds the index in the test. - Source-free key rotation cannot be in-place. Rotation keeps the old and new key valid together while each source is re-imported, then retires the old key only after every active endpoint has accepted the replacement bundle (ADR-0071). The server cannot perform the re-import because it never retains source values.
- No measurement of EDM on a real endpoint, for the same reason: nothing has run there.
SPIKE-005andedm_latency.rsmeasure the engine on a server-class host, andDLP-257records that an eight-field set costs about 5.9 s per megabyte - which will matter a great deal on the day datasets actually travel.