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", }