Skip to content

feat(compute-gateway): turn the read-path masking PDP ON - #1387

Merged
mdheller merged 1 commit into
mainfrom
feat/masking-pdp-enable
Aug 4, 2026
Merged

feat(compute-gateway): turn the read-path masking PDP ON#1387
mdheller merged 1 commit into
mainfrom
feat/masking-pdp-enable

Conversation

@mdheller

@mdheller mdheller commented Aug 4, 2026

Copy link
Copy Markdown
Member

⚠️ PRODUCTION BEHAVIOUR CHANGE. graph-query/graph-stats reads will begin returning masked direct identifiers to callers that previously received them in cleartext. Please sanity-check the field list against known consumers before merging.

The moat existed and was switched off

masking.py has been mounted in the compute-gateway dispatch path since it landed. With no policy configured it returns outputs unchanged. The 2026-08-04 audit found GATEWAY_MASKING_POLICY set nowhere under deploy/ — not a missing mechanism, an unused one.

So the WKC-parity claim rested on code that never ran. This turns it on.

What the policy does

email, email_address, phone, msisdn hmac_pseudonym
ssn, national_id one_way_hash
patient_mrn chameleon_token
date_of_birth, dob generalize
full_name, street_address redact

Plus the no_health_adtech veto as a forbidden_mixture: an adtech-realm requester reading health-topic records is denied and the records withheld, not masked.

The masking decision is appended as a sealed ComputeOutput, so the Ed25519 receipt attests exactly what the caller received. That's the property WKC-style dynamic masking cannot produce — there, enforcement leaves no verifiable artifact.

The policy is under test, from the values file

tests/test_masking_policy_values.py parses GATEWAY_MASKING_POLICY out of deploy/values/compute-gateway.yaml and exercises that string — not a fixture written to agree with it. A YAML typo now fails CI instead of silently reverting the gateway to passthrough, which is exactly how this ended up off in the first place.

Covered: identifiers masked while non-identifiers survive; the decision emitted; the veto withholding records entirely; an unknown scheme failing closed to redact rather than passing raw through.

Found while testing — asserted, not assumed

masking._iter_records reads data["nodes"] and returns [] for anything else. A result shaped as rows / table / edges is returned UNMASKED even with this policy active.

test_masking_coverage_boundary_is_nodes_only pins that limit so the next person reads it from a failing assertion rather than from an incident. Widening it means changing _iter_records — a bigger change than turning the policy on, deliberately not bundled here.

Verification

249 compute-gateway tests pass · preflight_deploy_contract.py green.

Pairs with SocioProphet/sociosphere#581, which binds masking-decision.v1 to a classification stance so the PDP can tell "confidently public" from "we have no idea what this is".

⚠️ PRODUCTION BEHAVIOUR CHANGE. graph-query/graph-stats reads will begin returning masked
direct identifiers to callers that previously received them in cleartext.

masking.py has been mounted in the dispatch path since it landed, but with no policy
configured it returned outputs unchanged. The 2026-08-04 audit found GATEWAY_MASKING_POLICY
set nowhere under deploy/ — not a missing mechanism, an unused one. The moat existed and was
switched off, so the WKC-parity claim rested on code that never ran.

The policy masks direct identifiers on read (email/phone/msisdn hmac_pseudonym, ssn/national_id
one_way_hash, patient_mrn chameleon_token, dob generalize, name/street redact) and encodes the
no_health_adtech veto as a forbidden_mixture: an adtech-realm requester reading health-topic
records is DENIED and the records are WITHHELD, not masked. The masking decision is appended
as a sealed ComputeOutput so the Ed25519 receipt attests exactly what the caller received —
the property WKC-style dynamic masking cannot produce, because there the enforcement leaves
no verifiable artifact.

tests/test_masking_policy_values.py parses GATEWAY_MASKING_POLICY out of the values file
itself and exercises THAT string, not a fixture written to agree with it. A YAML typo now
fails CI instead of silently reverting the gateway to passthrough — which is exactly how this
ended up off in the first place. Covered: identifiers masked and non-identifiers preserved,
the decision emitted, the veto withholding records, and an unknown scheme failing closed to
redact rather than passing raw through.

FOUND WHILE TESTING, and asserted rather than assumed: masking._iter_records reads
`data["nodes"]` and returns [] for anything else, so a result shaped as rows/table/edges is
returned UNMASKED even with this policy active. test_masking_coverage_boundary_is_nodes_only
pins that limit so the next person reads it from a failing assertion rather than an incident.
Widening it means changing _iter_records, which is a bigger change than turning the policy on
and is deliberately not bundled here.

249 compute-gateway tests pass; preflight_deploy_contract green.
@mdheller
mdheller merged commit ab0d669 into main Aug 4, 2026
94 of 95 checks passed
@mdheller
mdheller deleted the feat/masking-pdp-enable branch August 4, 2026 08:44
mdheller pushed a commit that referenced this pull request Aug 4, 2026
Closes a LIVE hole. The masking PDP went on in #1387, and `_iter_records` walked
`data["nodes"]` and nothing else — so a result shaped as rows / table / edges was returned
UNMASKED while the decision output reported the policy active. `rows` is a shape this estate
actually emits (hellgraph + compute-gateway both do), so personal data was being served in
cleartext on those paths with masking nominally enforced.

I pinned that limit with a test rather than fixing it when the policy landed. That was the
wrong call for something already enforcing in production.

The walker is now shape-agnostic and depth-bounded: every nested dict is a candidate record.
An allowlist of container keys only moves the hole to the next shape somebody adds. This is
safe because _mask_record rewrites ONLY keys named in the policy's mask_fields — a dict with
no configured field comes back untouched — so over-walking does nothing while under-walking
serves personal data. test_over_walking_is_harmless asserts a payload with no configured
field is returned byte-identical, so widening coverage cannot corrupt unrelated envelopes.

output.data itself is excluded: it is the envelope, not a record, and masking its top level
would rewrite container keys that happen to collide with a field name.

Tests: every shape (nodes/rows/edges/records) asserted to leak nothing; nested payloads
reached; over-walking harmless; READ_KINDS unchanged so widening the walker does not widen
the kinds governed. 252 pass.
mdheller pushed a commit that referenced this pull request Aug 4, 2026
Closes a LIVE hole. The masking PDP went on in #1387, and `_iter_records` walked
`data["nodes"]` and nothing else — so a result shaped as rows / table / edges was returned
UNMASKED while the decision output reported the policy active. `rows` is a shape this estate
actually emits (hellgraph + compute-gateway both do), so personal data was being served in
cleartext on those paths with masking nominally enforced.

I pinned that limit with a test rather than fixing it when the policy landed. That was the
wrong call for something already enforcing in production.

The walker is now shape-agnostic and depth-bounded: every nested dict is a candidate record.
An allowlist of container keys only moves the hole to the next shape somebody adds. This is
safe because _mask_record rewrites ONLY keys named in the policy's mask_fields — a dict with
no configured field comes back untouched — so over-walking does nothing while under-walking
serves personal data. test_over_walking_is_harmless asserts a payload with no configured
field is returned byte-identical, so widening coverage cannot corrupt unrelated envelopes.

output.data itself is excluded: it is the envelope, not a record, and masking its top level
would rewrite container keys that happen to collide with a field name.

Tests: every shape (nodes/rows/edges/records) asserted to leak nothing; nested payloads
reached; over-walking harmless; READ_KINDS unchanged so widening the walker does not widen
the kinds governed. 252 pass.
mdheller added a commit that referenced this pull request Aug 4, 2026
Closes a LIVE hole. The masking PDP went on in #1387, and `_iter_records` walked
`data["nodes"]` and nothing else — so a result shaped as rows / table / edges was returned
UNMASKED while the decision output reported the policy active. `rows` is a shape this estate
actually emits (hellgraph + compute-gateway both do), so personal data was being served in
cleartext on those paths with masking nominally enforced.

I pinned that limit with a test rather than fixing it when the policy landed. That was the
wrong call for something already enforcing in production.

The walker is now shape-agnostic and depth-bounded: every nested dict is a candidate record.
An allowlist of container keys only moves the hole to the next shape somebody adds. This is
safe because _mask_record rewrites ONLY keys named in the policy's mask_fields — a dict with
no configured field comes back untouched — so over-walking does nothing while under-walking
serves personal data. test_over_walking_is_harmless asserts a payload with no configured
field is returned byte-identical, so widening coverage cannot corrupt unrelated envelopes.

output.data itself is excluded: it is the envelope, not a record, and masking its top level
would rewrite container keys that happen to collide with a field name.

Tests: every shape (nodes/rows/edges/records) asserted to leak nothing; nested payloads
reached; over-walking harmless; READ_KINDS unchanged so widening the walker does not widen
the kinds governed. 252 pass.

Co-authored-by: mdheller <charles.peterson@socioprophet.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant