From 88d085dc91e840f1196db88fc7ed0b43a666f70b Mon Sep 17 00:00:00 2001 From: mdheller Date: Tue, 4 Aug 2026 03:43:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(masking):=20bind=20masking=20decisions=20t?= =?UTF-8?q?o=20classification=20stance=20=E2=80=94=20the=20missing=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The masking PDP decides what to DO with a field. The six-layer fingerprint classifier (SP-FPRINT-STACK-001) decides what a field IS. Nothing joined them, so masking-decision.v1 carried no data_class_ref, no stance, no confidence of any kind — and the PDP could not tell "this field is confidently public" from "we have no idea what this field is". Both arrived as the absence of a matching policy, and the unknown field was served. Adds applied_transforms[].classification { data_class_ref, stance, stance_ref, zero_disposition, zero_attestation_ref } and enforces DR-4 at this layer: * INADMISSIBLE_CLASSIFICATION_ALLOWED — a glut can never be resolved by allowing. If any field's stance is INADMISSIBLE the verdict must not be `allow`. INADMISSIBLE means two fingerprint layers disagree about what the field IS and one reading may well be "personal data"; serving it lets the contradiction settle in favour of whoever asked, which is the one resolution nobody chose. review_required is where a glut belongs. * ZERO_CLASSIFICATION_FAIL_OPEN_UNATTESTED — serving data whose classification is unknown requires an attestation. fail-open on ZERO is legitimate for a public reference table and a breach for a customer-master column, which is precisely why it is decided on the record rather than defaulted. An absent classification block reads as ZERO, never as safe. Both invariants are structural (draft 2020-12 if/then), not validator-only, with negative fixtures that must be rejected and a positive fixture showing the same shapes done right (glut -> review_required; ZERO fail-open with an attestation). 6 rejected, 5 accepted. The negative-fixture validator earned its keep here: the first version of the ZERO conditional was nested one level too deep — it sat inside the `classification` subschema, so its `if` looked for classification.classification and could never match. The schema accepted the under-masked fixture and the validator reported "ZERO_CLASSIFICATION_FAIL_OPEN_UNATTESTED has no teeth" rather than passing green. A conformance suite that only checks acceptance would have shipped it. --- ...ng_decision.classified_governed.valid.json | 57 +++ ...ssible_classification_allowed.invalid.json | 45 +++ ...sion.zero_failopen_unattested.invalid.json | 45 +++ .../schemas/masking-decision.v1.schema.json | 354 ++++++++++++++++-- ...idate_masking_extract_negative_fixtures.py | 42 +++ 5 files changed, 507 insertions(+), 36 deletions(-) create mode 100644 protocol/identity-is-prime/masking/fixtures/masking_decision.classified_governed.valid.json create mode 100644 protocol/identity-is-prime/masking/fixtures/negative/masking_decision.inadmissible_classification_allowed.invalid.json create mode 100644 protocol/identity-is-prime/masking/fixtures/negative/masking_decision.zero_failopen_unattested.invalid.json diff --git a/protocol/identity-is-prime/masking/fixtures/masking_decision.classified_governed.valid.json b/protocol/identity-is-prime/masking/fixtures/masking_decision.classified_governed.valid.json new file mode 100644 index 00000000..b6663530 --- /dev/null +++ b/protocol/identity-is-prime/masking/fixtures/masking_decision.classified_governed.valid.json @@ -0,0 +1,57 @@ +{ + "schema_version": "identity-prime.masking-decision.v1", + "decision_id": "md_classified_governed", + "subject_ref": "ce_patient_0042", + "requesting_realm": "ADTECH", + "audience": { + "role": "ad_audience_builder", + "openid_profile_attr": null + }, + "requested_op": "read", + "verdict": "review_required", + "reason_codes": [ + "FORBIDDEN_IDENTITY_MIXTURE", + "NO_HEALTH_ADTECH" + ], + "applied_transforms": [ + { + "field_path": "$.patient_mrn", + "scheme": "chameleon_token", + "reversibility": "reversible", + "classification": { + "data_class_ref": "urn:srcos:data-class:patient-mrn", + "stance": "INADMISSIBLE", + "stance_ref": "urn:srcos:stance:patient-mrn-conflict", + "zero_disposition": null, + "zero_attestation_ref": null + } + }, + { + "field_path": "$.lookup_code", + "scheme": "redact", + "reversibility": "one_way", + "classification": { + "data_class_ref": null, + "stance": "ZERO", + "stance_ref": null, + "zero_disposition": "fail-open", + "zero_attestation_ref": "urn:srcos:attestation:dr4-reference-lookup-2026-08-01" + } + } + ], + "re_identification": null, + "side_channel_mitigations": [ + "policy", + "unlinkable_identifiers", + "monitoring" + ], + "receipt": { + "policy_decision_ref": "pd_no_health_adtech_0001", + "beacon_commit_receipt": "beacon:placeholder-health-adtech-deny", + "certificate_hash": "sha256:masking-health-adtech-deny-placeholder" + }, + "policy_version": "regis.identity.polytope.core@0.1.0", + "scheme_version": "identity-prime.tokenization-profile.v1", + "decided_by": "masking-pdp-agent", + "created_at": "2026-06-29T15:04:00Z" +} diff --git a/protocol/identity-is-prime/masking/fixtures/negative/masking_decision.inadmissible_classification_allowed.invalid.json b/protocol/identity-is-prime/masking/fixtures/negative/masking_decision.inadmissible_classification_allowed.invalid.json new file mode 100644 index 00000000..58a1f768 --- /dev/null +++ b/protocol/identity-is-prime/masking/fixtures/negative/masking_decision.inadmissible_classification_allowed.invalid.json @@ -0,0 +1,45 @@ +{ + "schema_version": "identity-prime.masking-decision.v1", + "decision_id": "md_patient_adtech_read_inadmissible_allowed", + "subject_ref": "ce_patient_0042", + "requesting_realm": "ADTECH", + "audience": { + "role": "ad_audience_builder", + "openid_profile_attr": null + }, + "requested_op": "read", + "verdict": "allow", + "reason_codes": [ + "FORBIDDEN_IDENTITY_MIXTURE", + "NO_HEALTH_ADTECH" + ], + "applied_transforms": [ + { + "field_path": "$.patient_mrn", + "scheme": "chameleon_token", + "reversibility": "reversible", + "classification": { + "data_class_ref": "urn:srcos:data-class:patient-mrn", + "stance": "INADMISSIBLE", + "stance_ref": "urn:srcos:stance:patient-mrn-conflict", + "zero_disposition": null, + "zero_attestation_ref": null + } + } + ], + "re_identification": null, + "side_channel_mitigations": [ + "policy", + "unlinkable_identifiers", + "monitoring" + ], + "receipt": { + "policy_decision_ref": "pd_no_health_adtech_0001", + "beacon_commit_receipt": "beacon:placeholder-health-adtech-deny", + "certificate_hash": "sha256:masking-health-adtech-deny-placeholder" + }, + "policy_version": "regis.identity.polytope.core@0.1.0", + "scheme_version": "identity-prime.tokenization-profile.v1", + "decided_by": "masking-pdp-agent", + "created_at": "2026-06-29T15:04:00Z" +} diff --git a/protocol/identity-is-prime/masking/fixtures/negative/masking_decision.zero_failopen_unattested.invalid.json b/protocol/identity-is-prime/masking/fixtures/negative/masking_decision.zero_failopen_unattested.invalid.json new file mode 100644 index 00000000..17a4abe7 --- /dev/null +++ b/protocol/identity-is-prime/masking/fixtures/negative/masking_decision.zero_failopen_unattested.invalid.json @@ -0,0 +1,45 @@ +{ + "schema_version": "identity-prime.masking-decision.v1", + "decision_id": "md_zero_failopen_unattested", + "subject_ref": "ce_patient_0042", + "requesting_realm": "ADTECH", + "audience": { + "role": "ad_audience_builder", + "openid_profile_attr": null + }, + "requested_op": "read", + "verdict": "allow", + "reason_codes": [ + "FORBIDDEN_IDENTITY_MIXTURE", + "NO_HEALTH_ADTECH" + ], + "applied_transforms": [ + { + "field_path": "$.unknown_column", + "scheme": "chameleon_token", + "reversibility": "reversible", + "classification": { + "data_class_ref": null, + "stance": "ZERO", + "stance_ref": null, + "zero_disposition": "fail-open", + "zero_attestation_ref": null + } + } + ], + "re_identification": null, + "side_channel_mitigations": [ + "policy", + "unlinkable_identifiers", + "monitoring" + ], + "receipt": { + "policy_decision_ref": "pd_no_health_adtech_0001", + "beacon_commit_receipt": "beacon:placeholder-health-adtech-deny", + "certificate_hash": "sha256:masking-health-adtech-deny-placeholder" + }, + "policy_version": "regis.identity.polytope.core@0.1.0", + "scheme_version": "identity-prime.tokenization-profile.v1", + "decided_by": "masking-pdp-agent", + "created_at": "2026-06-29T15:04:00Z" +} diff --git a/protocol/identity-is-prime/masking/schemas/masking-decision.v1.schema.json b/protocol/identity-is-prime/masking/schemas/masking-decision.v1.schema.json index 112fe89d..cca72761 100644 --- a/protocol/identity-is-prime/masking/schemas/masking-decision.v1.schema.json +++ b/protocol/identity-is-prime/masking/schemas/masking-decision.v1.schema.json @@ -19,9 +19,17 @@ "created_at" ], "properties": { - "schema_version": { "const": "identity-prime.masking-decision.v1" }, - "decision_id": { "type": "string", "pattern": "^md_[a-zA-Z0-9_.:-]+$" }, - "subject_ref": { "type": "string", "description": "Asset/record/field the decision ranges over." }, + "schema_version": { + "const": "identity-prime.masking-decision.v1" + }, + "decision_id": { + "type": "string", + "pattern": "^md_[a-zA-Z0-9_.:-]+$" + }, + "subject_ref": { + "type": "string", + "description": "Asset/record/field the decision ranges over." + }, "requesting_realm": { "type": "string", "pattern": "^(CITIZEN_FOG|CITIZEN_CLOUD|INSTITUTION|ADTECH|HSM|DEVICE|APP|CLOUD)([A-Z0-9_]*[A-Z0-9])?$" @@ -31,24 +39,56 @@ "additionalProperties": false, "description": "Who is asking (role/profile). Re-identification gates on a profile attribute (OpenID Connect).", "properties": { - "role": { "type": ["string", "null"] }, - "openid_profile_attr": { "type": ["string", "null"] } + "role": { + "type": [ + "string", + "null" + ] + }, + "openid_profile_attr": { + "type": [ + "string", + "null" + ] + } } }, "requested_op": { "type": "string", - "enum": ["read", "export", "activate", "re_identify", "re_tokenize"] + "enum": [ + "read", + "export", + "activate", + "re_identify", + "re_tokenize" + ] }, "verdict": { "type": "string", "description": "PDP outcome. allow_masked = returnable only through the applied transforms.", - "enum": ["allow", "allow_masked", "deny", "review_required"] + "enum": [ + "allow", + "allow_masked", + "deny", + "review_required" + ] + }, + "reason_codes": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true }, - "reason_codes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "forbidden_mixture": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "description": "Populated when the deny is a policy-polytope veto (e.g. health + adtech).", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "applied_transforms": { "type": "array", @@ -56,56 +96,298 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["field_path", "scheme", "reversibility"], + "required": [ + "field_path", + "scheme", + "reversibility" + ], "properties": { - "field_path": { "type": "string" }, - "profile_ref": { "type": ["string", "null"], "pattern": "^tp_[a-zA-Z0-9_.:-]+$" }, - "scheme": { "type": "string" }, - "reversibility": { "type": "string", "enum": ["reversible", "one_way"] }, - "key_epoch": { "type": ["integer", "null"], "minimum": 0 }, - "target_domain": { "type": ["string", "null"], "description": "For re_tokenize: destination domain reached via homomorphic tweak." } - } + "field_path": { + "type": "string" + }, + "profile_ref": { + "type": [ + "string", + "null" + ], + "pattern": "^tp_[a-zA-Z0-9_.:-]+$" + }, + "scheme": { + "type": "string" + }, + "reversibility": { + "type": "string", + "enum": [ + "reversible", + "one_way" + ] + }, + "key_epoch": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "target_domain": { + "type": [ + "string", + "null" + ], + "description": "For re_tokenize: destination domain reached via homomorphic tweak." + }, + "classification": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "required": [ + "stance" + ], + "description": "What the six-layer fingerprint classifier concluded this field IS (SP-FPRINT-STACK-001). The masking PDP decides what to DO with a field; without this binding it cannot tell 'confidently public' from 'we have no idea what this is' \u2014 both arrive as the absence of a matching policy, and the unknown field is served.", + "properties": { + "data_class_ref": { + "type": [ + "string", + "null" + ], + "pattern": "^urn:srcos:data-class:" + }, + "stance": { + "enum": [ + "POS", + "NEG", + "ZERO", + "INADMISSIBLE" + ], + "description": "POS supported / NEG refuted / ZERO ignorance / INADMISSIBLE conflict. ZERO and INADMISSIBLE are DIFFERENT failures: a gap wants more evidence, a glut wants a human. An absent classification block is read as ZERO, never as safe." + }, + "stance_ref": { + "type": [ + "string", + "null" + ], + "pattern": "^urn:srcos:stance:" + }, + "zero_disposition": { + "type": [ + "string", + "null" + ], + "enum": [ + "fail-closed", + "fail-open", + null + ], + "description": "DR-4. What this PDP does when the field's classification is ZERO. fail-closed over-blocks (a cost in access); fail-open leaks (a cost in exposure). Neither is universally right, so it is an attested decision rather than a default." + }, + "zero_attestation_ref": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "allOf": [ + { + "if": { + "type": "object", + "required": [ + "classification" + ], + "properties": { + "classification": { + "type": "object", + "required": [ + "stance", + "zero_disposition" + ], + "properties": { + "stance": { + "const": "ZERO" + }, + "zero_disposition": { + "const": "fail-open" + } + } + } + } + }, + "then": { + "properties": { + "classification": { + "required": [ + "zero_attestation_ref" + ], + "properties": { + "zero_attestation_ref": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + ] } }, "re_identification": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": false, "description": "Present only when requested_op = re_identify. Re-id is a governed, audited, reversible release.", - "required": ["permitted", "reason_for_action"], + "required": [ + "permitted", + "reason_for_action" + ], "properties": { - "permitted": { "type": "boolean" }, - "reason_for_action": { "type": "string", "minLength": 1, "description": "Mandatory free-text justification — recorded for audit." }, - "authorized_by": { "type": ["string", "null"] }, - "separation_of_duty": { "type": "boolean", "default": false }, - "release_pack_ref": { "type": ["string", "null"], "description": "HELL-ER release pack, if a bundle is emitted." } + "permitted": { + "type": "boolean" + }, + "reason_for_action": { + "type": "string", + "minLength": 1, + "description": "Mandatory free-text justification \u2014 recorded for audit." + }, + "authorized_by": { + "type": [ + "string", + "null" + ] + }, + "separation_of_duty": { + "type": "boolean", + "default": false + }, + "release_pack_ref": { + "type": [ + "string", + "null" + ], + "description": "HELL-ER release pack, if a bundle is emitted." + } } }, "side_channel_mitigations": { "type": "array", "uniqueItems": true, "description": "Active mitigations against the 4 re-identification risks.", - "items": { "type": "string", "enum": ["sandbox", "monitoring", "policy", "collusion_prevention", "separation_of_duty", "trusted_third_party", "key_rotation", "unlinkable_identifiers"] } + "items": { + "type": "string", + "enum": [ + "sandbox", + "monitoring", + "policy", + "collusion_prevention", + "separation_of_duty", + "trusted_third_party", + "key_rotation", + "unlinkable_identifiers" + ] + } }, "receipt": { "type": "object", "additionalProperties": false, - "required": ["policy_decision_ref"], + "required": [ + "policy_decision_ref" + ], "description": "Makes the decision itself verifiable evidence.", "properties": { - "policy_decision_ref": { "type": "string" }, - "beacon_commit_receipt": { "type": ["string", "null"], "description": "TriTRPC BEACON_COMMIT receipt sealing this decision into the custody chain." }, - "certificate_hash": { "type": ["string", "null"] } + "policy_decision_ref": { + "type": "string" + }, + "beacon_commit_receipt": { + "type": [ + "string", + "null" + ], + "description": "TriTRPC BEACON_COMMIT receipt sealing this decision into the custody chain." + }, + "certificate_hash": { + "type": [ + "string", + "null" + ] + } } }, - "policy_version": { "type": "string" }, - "scheme_version": { "type": ["string", "null"] }, - "decided_by": { "type": "string", "description": "Agent identity (e.g. masking-pdp-agent)." }, - "created_at": { "type": "string", "format": "date-time" } + "policy_version": { + "type": "string" + }, + "scheme_version": { + "type": [ + "string", + "null" + ] + }, + "decided_by": { + "type": "string", + "description": "Agent identity (e.g. masking-pdp-agent)." + }, + "created_at": { + "type": "string", + "format": "date-time" + } }, "allOf": [ { - "if": { "properties": { "requested_op": { "const": "re_identify" } } }, - "then": { "required": ["re_identification"] } + "if": { + "properties": { + "requested_op": { + "const": "re_identify" + } + } + }, + "then": { + "required": [ + "re_identification" + ] + } + }, + { + "if": { + "properties": { + "applied_transforms": { + "contains": { + "properties": { + "classification": { + "type": "object", + "properties": { + "stance": { + "const": "INADMISSIBLE" + } + }, + "required": [ + "stance" + ] + } + }, + "required": [ + "classification" + ] + } + } + }, + "required": [ + "applied_transforms" + ] + }, + "then": { + "properties": { + "verdict": { + "not": { + "const": "allow" + } + } + } + } } ] } diff --git a/tools/conformance/validate_masking_extract_negative_fixtures.py b/tools/conformance/validate_masking_extract_negative_fixtures.py index 925d688d..be77bdc4 100644 --- a/tools/conformance/validate_masking_extract_negative_fixtures.py +++ b/tools/conformance/validate_masking_extract_negative_fixtures.py @@ -60,6 +60,37 @@ def _reidentify_without_reason(d: dict[str, Any]) -> bool: return not reid.get("reason_for_action") +def _classifications(d: dict[str, Any]) -> list[dict[str, Any]]: + out = [] + for tr in d.get("applied_transforms") or []: + c = tr.get("classification") if isinstance(tr, dict) else None + if isinstance(c, dict): + out.append(c) + return out + + +def _inadmissible_classification_allowed(d: dict[str, Any]) -> bool: + """A glut resolved in the requester's favour. + + INADMISSIBLE means two fingerprint layers disagree about what this field IS, and one of + the readings may well be "personal data". Serving it lets the contradiction settle itself + in favour of whoever asked — the one resolution nobody chose. + """ + if d.get("verdict") != "allow": + return False + return any(c.get("stance") == "INADMISSIBLE" for c in _classifications(d)) + + +def _zero_failopen_unattested(d: dict[str, Any]) -> bool: + """Data whose classification is unknown, served with nobody on the record for it. + + fail-open on ZERO is a legitimate choice for a public reference table and a breach for a + customer-master column. That is exactly why it is attested rather than defaulted. + """ + return any(c.get("stance") == "ZERO" and c.get("zero_disposition") == "fail-open" + and not c.get("zero_attestation_ref") for c in _classifications(d)) + + def _empty_feature_sets(d: dict[str, Any]) -> bool: return d.get("feature_sets") == [] @@ -78,6 +109,14 @@ def _empty_feature_sets(d: dict[str, Any]) -> bool: "masking/schemas/masking-decision.v1.schema.json", _reidentify_without_reason, ), + "INADMISSIBLE_CLASSIFICATION_ALLOWED": ( + "masking/schemas/masking-decision.v1.schema.json", + _inadmissible_classification_allowed, + ), + "ZERO_CLASSIFICATION_FAIL_OPEN_UNATTESTED": ( + "masking/schemas/masking-decision.v1.schema.json", + _zero_failopen_unattested, + ), "EMPTY_FEATURE_SETS": ( "extract/schemas/grasp-pattern.v1.schema.json", _empty_feature_sets, @@ -89,6 +128,8 @@ def _empty_feature_sets(d: dict[str, Any]) -> bool: "masking/fixtures/negative/tokenization_profile.cross_domain_linkable.invalid.json": "CROSS_DOMAIN_LINKABLE_TOKEN", "masking/fixtures/negative/tokenization_profile.under_masked_hmac.invalid.json": "ONE_WAY_SCHEME_DECLARED_REVERSIBLE", "masking/fixtures/negative/masking_decision.reidentify_without_reason.invalid.json": "REIDENTIFY_WITHOUT_REASON", + "masking/fixtures/negative/masking_decision.inadmissible_classification_allowed.invalid.json": "INADMISSIBLE_CLASSIFICATION_ALLOWED", + "masking/fixtures/negative/masking_decision.zero_failopen_unattested.invalid.json": "ZERO_CLASSIFICATION_FAIL_OPEN_UNATTESTED", "extract/fixtures/negative/grasp_pattern.empty_feature_sets.invalid.json": "EMPTY_FEATURE_SETS", } @@ -97,6 +138,7 @@ def _empty_feature_sets(d: dict[str, Any]) -> bool: "masking/fixtures/tokenization_profile.chameleon_patient_mrn.valid.json": "masking/schemas/tokenization-profile.v1.schema.json", "masking/fixtures/masking_decision.reidentify_with_reason.valid.json": "masking/schemas/masking-decision.v1.schema.json", "masking/fixtures/masking_decision.health_adtech_deny.valid.json": "masking/schemas/masking-decision.v1.schema.json", + "masking/fixtures/masking_decision.classified_governed.valid.json": "masking/schemas/masking-decision.v1.schema.json", "extract/fixtures/grasp_pattern.compliance_sentence.valid.json": "extract/schemas/grasp-pattern.v1.schema.json", }