maskflow-evidence 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. maskflow_evidence-0.1.0/.gitignore +16 -0
  2. maskflow_evidence-0.1.0/PKG-INFO +73 -0
  3. maskflow_evidence-0.1.0/README.md +55 -0
  4. maskflow_evidence-0.1.0/contrib/grafana-dashboard.json +117 -0
  5. maskflow_evidence-0.1.0/pyproject.toml +51 -0
  6. maskflow_evidence-0.1.0/src/maskflow_evidence/__init__.py +50 -0
  7. maskflow_evidence-0.1.0/src/maskflow_evidence/config.py +125 -0
  8. maskflow_evidence-0.1.0/src/maskflow_evidence/derive.py +136 -0
  9. maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/__init__.py +98 -0
  10. maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/file.py +44 -0
  11. maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/otlp.py +49 -0
  12. maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/py.typed +0 -0
  13. maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/stdout.py +20 -0
  14. maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/syslog.py +31 -0
  15. maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/webhook.py +36 -0
  16. maskflow_evidence-0.1.0/src/maskflow_evidence/guard.py +117 -0
  17. maskflow_evidence-0.1.0/src/maskflow_evidence/py.typed +0 -0
  18. maskflow_evidence-0.1.0/src/maskflow_evidence/schema.py +182 -0
  19. maskflow_evidence-0.1.0/src/maskflow_evidence/versions.py +54 -0
  20. maskflow_evidence-0.1.0/tests/conftest.py +5 -0
  21. maskflow_evidence-0.1.0/tests/test_contrib.py +16 -0
  22. maskflow_evidence-0.1.0/tests/test_derive.py +99 -0
  23. maskflow_evidence-0.1.0/tests/test_derive_no_value_access.py +23 -0
  24. maskflow_evidence-0.1.0/tests/test_emitters.py +85 -0
  25. maskflow_evidence-0.1.0/tests/test_evidence_config.py +68 -0
  26. maskflow_evidence-0.1.0/tests/test_schema_metadata_only.py +158 -0
  27. maskflow_evidence-0.1.0/tests/test_versions.py +24 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ node_modules/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .DS_Store
11
+ .pytest_cache/
12
+ .idea/
13
+ .coverage
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ bench/indiapii/quality/.cache/
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: maskflow-evidence
3
+ Version: 0.1.0
4
+ Summary: MaskFlow evidence layer: a metadata-only, verifiable record of what was masked -- never the values. Self-hosted emitters (stdout, file, syslog, webhook, OTLP), off by default.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: maskflow-core<0.8,>=0.7.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: httpx>=0.27; extra == 'dev'
10
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Provides-Extra: otlp
13
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.25; extra == 'otlp'
14
+ Requires-Dist: opentelemetry-sdk>=1.25; extra == 'otlp'
15
+ Provides-Extra: webhook
16
+ Requires-Dist: httpx>=0.27; extra == 'webhook'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # maskflow-evidence
20
+
21
+ A **metadata-only, verifiable record of what MaskFlow masked** — never the
22
+ values themselves. Publishing the schema and shipping the emitters in the
23
+ open lets any user confirm, by reading the code, exactly what leaves their
24
+ environment.
25
+
26
+ > Part of [MaskFlow](https://github.com/maskflow/maskflow). MIT, free
27
+ > forever, no telemetry.
28
+
29
+ ## What an event looks like
30
+
31
+ ```json
32
+ {"event_id":"…","ts":"2026-09-07T12:00:00Z","session_id":"…","service":"support-bot",
33
+ "environment":"prod","entity_type":"AADHAAR","count":1,"score":0.98,
34
+ "recognizer":"pattern:AADHAAR","action":"masked","provider":"openai","model":"gpt-4o",
35
+ "pack_version":"0.5.0","engine_version":"0.6.0"}
36
+ ```
37
+
38
+ There is **no field that can carry free text.** Every string is a bounded
39
+ slug; there is no place for a detected value, a placeholder, or the mapping
40
+ between them. This is enforced structurally in `schema.py` and in CI by
41
+ `tests/test_schema_metadata_only.py`.
42
+
43
+ ## Off by default
44
+
45
+ Nothing is emitted unless you turn it on. In `.maskflowrc`:
46
+
47
+ ```toml
48
+ [evidence]
49
+ enabled = true
50
+ sink = "file" # stdout | file | syslog | webhook | otlp
51
+ path = "evidence.log"
52
+ service = "support-bot"
53
+ environment = "prod"
54
+ ```
55
+
56
+ The gateway reads `MASKFLOW_GATEWAY_EVIDENCE_*` environment variables with
57
+ the same names.
58
+
59
+ ## Sinks
60
+
61
+ | sink | transport | extra |
62
+ |---|---|---|
63
+ | `stdout` | one JSON line per event | — |
64
+ | `file` | size-rotated JSON lines | — |
65
+ | `syslog` | `SysLogHandler` | — |
66
+ | `webhook` | `POST` JSON per event | `maskflow-evidence[webhook]` |
67
+ | `otlp` | OpenTelemetry log records | `maskflow-evidence[otlp]` |
68
+
69
+ ## What is deliberately **not** collected
70
+
71
+ Raw values, masked values, the mapping, request/response bodies, prompt
72
+ text, and any user identifier beyond a caller-supplied opaque `session_id`.
73
+ See `docs/evidence.md` for the full statement.
@@ -0,0 +1,55 @@
1
+ # maskflow-evidence
2
+
3
+ A **metadata-only, verifiable record of what MaskFlow masked** — never the
4
+ values themselves. Publishing the schema and shipping the emitters in the
5
+ open lets any user confirm, by reading the code, exactly what leaves their
6
+ environment.
7
+
8
+ > Part of [MaskFlow](https://github.com/maskflow/maskflow). MIT, free
9
+ > forever, no telemetry.
10
+
11
+ ## What an event looks like
12
+
13
+ ```json
14
+ {"event_id":"…","ts":"2026-09-07T12:00:00Z","session_id":"…","service":"support-bot",
15
+ "environment":"prod","entity_type":"AADHAAR","count":1,"score":0.98,
16
+ "recognizer":"pattern:AADHAAR","action":"masked","provider":"openai","model":"gpt-4o",
17
+ "pack_version":"0.5.0","engine_version":"0.6.0"}
18
+ ```
19
+
20
+ There is **no field that can carry free text.** Every string is a bounded
21
+ slug; there is no place for a detected value, a placeholder, or the mapping
22
+ between them. This is enforced structurally in `schema.py` and in CI by
23
+ `tests/test_schema_metadata_only.py`.
24
+
25
+ ## Off by default
26
+
27
+ Nothing is emitted unless you turn it on. In `.maskflowrc`:
28
+
29
+ ```toml
30
+ [evidence]
31
+ enabled = true
32
+ sink = "file" # stdout | file | syslog | webhook | otlp
33
+ path = "evidence.log"
34
+ service = "support-bot"
35
+ environment = "prod"
36
+ ```
37
+
38
+ The gateway reads `MASKFLOW_GATEWAY_EVIDENCE_*` environment variables with
39
+ the same names.
40
+
41
+ ## Sinks
42
+
43
+ | sink | transport | extra |
44
+ |---|---|---|
45
+ | `stdout` | one JSON line per event | — |
46
+ | `file` | size-rotated JSON lines | — |
47
+ | `syslog` | `SysLogHandler` | — |
48
+ | `webhook` | `POST` JSON per event | `maskflow-evidence[webhook]` |
49
+ | `otlp` | OpenTelemetry log records | `maskflow-evidence[otlp]` |
50
+
51
+ ## What is deliberately **not** collected
52
+
53
+ Raw values, masked values, the mapping, request/response bodies, prompt
54
+ text, and any user identifier beyond a caller-supplied opaque `session_id`.
55
+ See `docs/evidence.md` for the full statement.
@@ -0,0 +1,117 @@
1
+ {
2
+ "__inputs": [
3
+ {
4
+ "name": "DS_PROMETHEUS",
5
+ "label": "Prometheus",
6
+ "description": "Prometheus datasource scraping the MaskFlow Gateway /metrics endpoint",
7
+ "type": "datasource",
8
+ "pluginId": "prometheus",
9
+ "pluginName": "Prometheus"
10
+ }
11
+ ],
12
+ "annotations": { "list": [] },
13
+ "description": "MaskFlow evidence: what was masked, from gateway Prometheus metrics. Panels only -- no compliance-control framing (pending issue #42).",
14
+ "editable": true,
15
+ "graphTooltip": 0,
16
+ "panels": [
17
+ {
18
+ "type": "row",
19
+ "title": "Evidence emission",
20
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
21
+ "id": 10
22
+ },
23
+ {
24
+ "type": "stat",
25
+ "title": "Evidence events emitted (5m rate)",
26
+ "description": "maskflow_evidence_emitted_total -- zero unless [evidence] is enabled on the gateway.",
27
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
28
+ "gridPos": { "h": 6, "w": 8, "x": 0, "y": 1 },
29
+ "id": 11,
30
+ "targets": [
31
+ { "expr": "sum(rate(maskflow_evidence_emitted_total[5m]))", "refId": "A" }
32
+ ],
33
+ "fieldConfig": { "defaults": { "unit": "ops" }, "overrides": [] }
34
+ },
35
+ {
36
+ "type": "timeseries",
37
+ "title": "Evidence events by sink",
38
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
39
+ "gridPos": { "h": 6, "w": 16, "x": 8, "y": 1 },
40
+ "id": 12,
41
+ "targets": [
42
+ {
43
+ "expr": "sum by (sink) (rate(maskflow_evidence_emitted_total[5m]))",
44
+ "legendFormat": "{{sink}}",
45
+ "refId": "A"
46
+ }
47
+ ]
48
+ },
49
+ {
50
+ "type": "row",
51
+ "title": "What was masked",
52
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 7 },
53
+ "id": 20
54
+ },
55
+ {
56
+ "type": "timeseries",
57
+ "title": "Detections by entity type (request side, 5m rate)",
58
+ "description": "maskflow_detections_total{direction=\"request\"} -- PII masked before it reached the provider.",
59
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
60
+ "gridPos": { "h": 9, "w": 12, "x": 0, "y": 8 },
61
+ "id": 21,
62
+ "targets": [
63
+ {
64
+ "expr": "sum by (entity_type) (rate(maskflow_detections_total{direction=\"request\"}[5m]))",
65
+ "legendFormat": "{{entity_type}}",
66
+ "refId": "A"
67
+ }
68
+ ]
69
+ },
70
+ {
71
+ "type": "piechart",
72
+ "title": "Detection mix by entity type (last 24h)",
73
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
74
+ "gridPos": { "h": 9, "w": 12, "x": 12, "y": 8 },
75
+ "id": 22,
76
+ "targets": [
77
+ {
78
+ "expr": "sum by (entity_type) (increase(maskflow_detections_total[24h]))",
79
+ "legendFormat": "{{entity_type}}",
80
+ "refId": "A"
81
+ }
82
+ ]
83
+ },
84
+ {
85
+ "type": "timeseries",
86
+ "title": "Mask stage latency (p50 / p95 / p99)",
87
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
88
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 17 },
89
+ "id": 30,
90
+ "targets": [
91
+ {
92
+ "expr": "histogram_quantile(0.50, sum by (le) (rate(maskflow_stage_latency_seconds_bucket{stage=\"mask\"}[5m])))",
93
+ "legendFormat": "p50",
94
+ "refId": "A"
95
+ },
96
+ {
97
+ "expr": "histogram_quantile(0.95, sum by (le) (rate(maskflow_stage_latency_seconds_bucket{stage=\"mask\"}[5m])))",
98
+ "legendFormat": "p95",
99
+ "refId": "B"
100
+ },
101
+ {
102
+ "expr": "histogram_quantile(0.99, sum by (le) (rate(maskflow_stage_latency_seconds_bucket{stage=\"mask\"}[5m])))",
103
+ "legendFormat": "p99",
104
+ "refId": "C"
105
+ }
106
+ ],
107
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
108
+ }
109
+ ],
110
+ "schemaVersion": 39,
111
+ "tags": ["maskflow", "evidence", "pii"],
112
+ "templating": { "list": [] },
113
+ "time": { "from": "now-6h", "to": "now" },
114
+ "title": "MaskFlow Evidence",
115
+ "uid": "maskflow-evidence",
116
+ "version": 1
117
+ }
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "maskflow-evidence"
3
+ version = "0.1.0"
4
+ description = "MaskFlow evidence layer: a metadata-only, verifiable record of what was masked -- never the values. Self-hosted emitters (stdout, file, syslog, webhook, OTLP), off by default."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ # Span / Mapping / Strategy / PIIType come from the engine, and the
10
+ # metadata-only leak assertion reuses core's detect_patterns_only(). No
11
+ # SDK dependency -- the evidence layer never masks, it only records what
12
+ # a mask() call already decided.
13
+ "maskflow-core>=0.7.0,<0.8",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ # The webhook emitter POSTs one JSON object per event. httpx only; kept out
18
+ # of the base install so `import maskflow_evidence` stays dependency-light
19
+ # for the stdout/file/syslog sinks (all stdlib).
20
+ webhook = [
21
+ "httpx>=0.27",
22
+ ]
23
+ # The OTLP emitter ships events as OpenTelemetry log records. The OTel SDK
24
+ # tree is large; opt in only if you already run an OTel collector.
25
+ otlp = [
26
+ "opentelemetry-sdk>=1.25",
27
+ "opentelemetry-exporter-otlp-proto-http>=1.25",
28
+ ]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "hypothesis>=6.100",
32
+ "httpx>=0.27",
33
+ ]
34
+
35
+ [tool.uv.sources]
36
+ maskflow-core = { workspace = true }
37
+
38
+ [tool.uv]
39
+ package = true
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/maskflow_evidence"]
47
+
48
+ [tool.pytest.ini_options]
49
+ markers = [
50
+ "leak: guards against a raw or masked value, or the mapping, reaching an evidence event",
51
+ ]
@@ -0,0 +1,50 @@
1
+ """MaskFlow evidence layer.
2
+
3
+ A metadata-only, verifiable record of *what was masked* -- never the values.
4
+ See ``docs/evidence.md`` for the schema, what is deliberately not collected,
5
+ and the self-hosted setup guide.
6
+
7
+ Off by default: nothing is emitted unless ``[evidence] enabled = true`` (or
8
+ the equivalent env var) selects a sink.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .config import EvidenceConfig
14
+ from .derive import (
15
+ EventContext,
16
+ action_for_strategy,
17
+ events_from_mapping,
18
+ events_from_spans,
19
+ )
20
+ from .emitters import Emitter, NullEmitter, SafeEmitter, build_emitter
21
+ from .guard import (
22
+ MetadataOnlyViolation,
23
+ assert_no_pii,
24
+ assert_schema_is_metadata_only,
25
+ scan_for_pii,
26
+ )
27
+ from .schema import ACTIONS, EvidenceEvent, EvidenceSchemaError
28
+ from .versions import engine_version, pack_version, pack_versions
29
+
30
+ __all__ = [
31
+ "ACTIONS",
32
+ "EvidenceConfig",
33
+ "EvidenceEvent",
34
+ "EvidenceSchemaError",
35
+ "EventContext",
36
+ "Emitter",
37
+ "NullEmitter",
38
+ "SafeEmitter",
39
+ "MetadataOnlyViolation",
40
+ "action_for_strategy",
41
+ "assert_no_pii",
42
+ "assert_schema_is_metadata_only",
43
+ "build_emitter",
44
+ "events_from_mapping",
45
+ "events_from_spans",
46
+ "engine_version",
47
+ "pack_version",
48
+ "pack_versions",
49
+ "scan_for_pii",
50
+ ]
@@ -0,0 +1,125 @@
1
+ """``EvidenceConfig`` -- the resolved settings :func:`build_emitter` needs,
2
+ plus the two ways to obtain it:
3
+
4
+ * :meth:`EvidenceConfig.from_rootconfig` -- from a ``maskflow_core`` resolved
5
+ config's ``[evidence]`` section (the CLI path);
6
+ * :meth:`EvidenceConfig.from_env` -- from ``MASKFLOW_..._EVIDENCE_*``
7
+ environment variables (the gateway path, which is env-only by design).
8
+
9
+ Evidence is **off by default** in both.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ _SINKS = ("stdout", "file", "syslog", "webhook", "otlp")
20
+
21
+
22
+ def _as_bool(value: Any, default: bool) -> bool:
23
+ if isinstance(value, bool):
24
+ return value
25
+ if isinstance(value, str):
26
+ return value.strip().lower() in ("1", "true", "yes", "on")
27
+ return default
28
+
29
+
30
+ def _as_int(value: Any, default: int) -> int:
31
+ try:
32
+ return int(value)
33
+ except (TypeError, ValueError):
34
+ return default
35
+
36
+
37
+ def _as_float(value: Any, default: float) -> float:
38
+ try:
39
+ return float(value)
40
+ except (TypeError, ValueError):
41
+ return default
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class EvidenceConfig:
46
+ enabled: bool = False
47
+ sink: str = "file"
48
+ # file sink
49
+ path: str = "evidence.log"
50
+ max_bytes: int = 10_000_000
51
+ backups: int = 5
52
+ # webhook / otlp sink
53
+ url: str = ""
54
+ timeout: float = 2.0
55
+ # syslog sink -- empty host means the local socket (/dev/log, or the
56
+ # macOS equivalent), which is what most deployments want.
57
+ syslog_host: str = ""
58
+ syslog_port: int = 514
59
+ # context stamped onto every event
60
+ service: str = "maskflow"
61
+ environment: str = "production"
62
+
63
+ def __post_init__(self) -> None:
64
+ if self.sink not in _SINKS:
65
+ raise ValueError(f"[evidence] sink must be one of {_SINKS}, got {self.sink!r}")
66
+
67
+ def syslog_address(self) -> str | tuple[str, int]:
68
+ if self.syslog_host:
69
+ return (self.syslog_host, self.syslog_port)
70
+ return "/dev/log"
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: Mapping[str, Any]) -> EvidenceConfig:
74
+ base = cls()
75
+ return cls(
76
+ enabled=_as_bool(data.get("enabled"), base.enabled),
77
+ sink=str(data.get("sink", base.sink)),
78
+ path=str(data.get("path", base.path)),
79
+ max_bytes=_as_int(data.get("max_bytes"), base.max_bytes),
80
+ backups=_as_int(data.get("backups"), base.backups),
81
+ url=str(data.get("url", base.url)),
82
+ timeout=_as_float(data.get("timeout"), base.timeout),
83
+ syslog_host=str(data.get("syslog_host", base.syslog_host)),
84
+ syslog_port=_as_int(data.get("syslog_port"), base.syslog_port),
85
+ service=str(data.get("service", base.service)),
86
+ environment=str(data.get("environment", base.environment)),
87
+ )
88
+
89
+ @classmethod
90
+ def from_rootconfig(cls, root_config: Any) -> EvidenceConfig:
91
+ """Read the ``evidence`` section off a ``maskflow_core`` ``RootConfig``
92
+ (or anything with a compatible ``.evidence`` attribute). A config with
93
+ no ``[evidence]`` section yields the disabled default."""
94
+ section = getattr(root_config, "evidence", None)
95
+ if section is None:
96
+ return cls()
97
+ if isinstance(section, Mapping):
98
+ return cls.from_dict(section)
99
+ # a dataclass section -> pull known attributes
100
+ keys = (
101
+ "enabled",
102
+ "sink",
103
+ "path",
104
+ "max_bytes",
105
+ "backups",
106
+ "url",
107
+ "timeout",
108
+ "syslog_host",
109
+ "syslog_port",
110
+ "service",
111
+ "environment",
112
+ )
113
+ return cls.from_dict({k: getattr(section, k) for k in keys if hasattr(section, k)})
114
+
115
+ @classmethod
116
+ def from_env(cls, prefix: str, env: Mapping[str, str] | None = None) -> EvidenceConfig:
117
+ """Build from ``{prefix}ENABLED``, ``{prefix}SINK``, ... (e.g.
118
+ ``prefix="MASKFLOW_GATEWAY_EVIDENCE_"``)."""
119
+ source = env if env is not None else os.environ
120
+ picked = {
121
+ key[len(prefix) :].lower(): value
122
+ for key, value in source.items()
123
+ if key.startswith(prefix)
124
+ }
125
+ return cls.from_dict(picked)
@@ -0,0 +1,136 @@
1
+ """Turn a masking decision into evidence events -- without ever reading a
2
+ detected value.
3
+
4
+ ``derive.py`` may touch ``Span.start``/``end``/``entity_type``/``score``/
5
+ ``recognizer`` and ``MappingEntry.entity_type``/``strategy``/``score``/
6
+ ``recognizer``. It must never read ``Span.text`` or ``MappingEntry.original``
7
+ -- ``tests/test_derive_no_value_access.py`` enforces this with an AST check.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import defaultdict
13
+ from collections.abc import Callable, Iterable, Sequence
14
+ from dataclasses import dataclass
15
+
16
+ from maskflow_core import Mapping, Span
17
+ from maskflow_core.strategies import Strategy
18
+
19
+ from .schema import EvidenceEvent
20
+ from .versions import engine_version, pack_version
21
+
22
+ # maskflow_core.Strategy -> evidence `action`. HASH collapses to "masked":
23
+ # from an evidence standpoint the value was replaced by something
24
+ # non-recoverable, same category as MASK/REDACT is "redacted".
25
+ _STRATEGY_ACTION: dict[Strategy, str] = {
26
+ Strategy.REPLACE: "masked",
27
+ Strategy.MASK: "masked",
28
+ Strategy.HASH: "masked",
29
+ Strategy.REDACT: "redacted",
30
+ Strategy.SURROGATE: "surrogate",
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class EventContext:
36
+ """The invariant part of every event from one masking call. All fields
37
+ are validated as slugs by ``EvidenceEvent`` -- a caller that passes an
38
+ email as ``session_id`` gets an ``EvidenceSchemaError``."""
39
+
40
+ session_id: str
41
+ service: str
42
+ environment: str
43
+ provider: str | None = None
44
+ model: str | None = None
45
+
46
+
47
+ def _emit_group(
48
+ ctx: EventContext,
49
+ entity_type: str,
50
+ recognizer: str,
51
+ action: str,
52
+ scores: list[float],
53
+ ) -> EvidenceEvent:
54
+ return EvidenceEvent(
55
+ entity_type=entity_type,
56
+ count=len(scores),
57
+ # The most conservative claim: the weakest detection in the group.
58
+ score=min(scores),
59
+ recognizer=recognizer,
60
+ action=action,
61
+ service=ctx.service,
62
+ environment=ctx.environment,
63
+ session_id=ctx.session_id,
64
+ provider=ctx.provider,
65
+ model=ctx.model,
66
+ pack_version=pack_version(),
67
+ engine_version=engine_version(),
68
+ )
69
+
70
+
71
+ def events_from_spans(
72
+ spans: Sequence[Span],
73
+ ctx: EventContext,
74
+ *,
75
+ strategy_for: Callable[[str], str] | None = None,
76
+ dropped: Iterable[Span] = (),
77
+ ) -> list[EvidenceEvent]:
78
+ """One event per ``(entity_type, recognizer, action)`` across the
79
+ resolved ``spans`` (plus any ``dropped`` spans, which become
80
+ ``action="passed"``).
81
+
82
+ ``strategy_for`` maps an entity-type name to an evidence action
83
+ (``"masked"``/``"redacted"``/``"surrogate"``); when omitted every
84
+ resolved span is treated as ``"masked"``.
85
+ """
86
+ groups: dict[tuple[str, str, str], list[float]] = defaultdict(list)
87
+
88
+ for span in spans:
89
+ etype = span.entity_type.value
90
+ action = strategy_for(etype) if strategy_for is not None else "masked"
91
+ groups[(etype, span.recognizer, action)].append(span.score)
92
+
93
+ for span in dropped:
94
+ groups[(span.entity_type.value, span.recognizer, "passed")].append(span.score)
95
+
96
+ return [
97
+ _emit_group(ctx, etype, recognizer, action, scores)
98
+ for (etype, recognizer, action), scores in sorted(groups.items())
99
+ ]
100
+
101
+
102
+ def action_for_strategy(strategy: Strategy) -> str:
103
+ return _STRATEGY_ACTION.get(strategy, "masked")
104
+
105
+
106
+ def events_from_mapping(
107
+ mapping: Mapping,
108
+ ctx: EventContext,
109
+ *,
110
+ only_tokens: Iterable[str] | None = None,
111
+ ) -> list[EvidenceEvent]:
112
+ """Events from a ``mask_with_policy`` result -- the gateway path, which
113
+ holds the session ``Mapping`` rather than the span list.
114
+
115
+ ``only_tokens`` restricts to tokens added by the current request (the
116
+ gateway diffs the mapping before/after each mask call). ``score`` and
117
+ ``recognizer`` come from the ``MappingEntry`` when present (core >=
118
+ 0.6.x populates them); otherwise ``recognizer`` is ``"unknown"`` and the
119
+ group score defaults to ``1.0``.
120
+ """
121
+ wanted = set(only_tokens) if only_tokens is not None else None
122
+ groups: dict[tuple[str, str, str], list[float]] = defaultdict(list)
123
+
124
+ for token, entry in mapping.items():
125
+ if wanted is not None and token not in wanted:
126
+ continue
127
+ etype = entry.entity_type.value
128
+ action = action_for_strategy(entry.strategy)
129
+ recognizer = getattr(entry, "recognizer", None) or "unknown"
130
+ score = getattr(entry, "score", None)
131
+ groups[(etype, recognizer, action)].append(1.0 if score is None else float(score))
132
+
133
+ return [
134
+ _emit_group(ctx, etype, recognizer, action, scores)
135
+ for (etype, recognizer, action), scores in sorted(groups.items())
136
+ ]