ferpa-haystack 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.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - name: Install dependencies
23
+ run: |
24
+ pip install hatch
25
+ hatch env create
26
+ - name: Run tests
27
+ run: hatch run test
28
+ - name: Lint
29
+ run: hatch run lint
@@ -0,0 +1,25 @@
1
+ name: Release to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ environment: release
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Set up Python
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.11"
18
+ - name: Install hatch
19
+ run: pip install hatch
20
+ - name: Build package
21
+ run: hatch build
22
+ - name: Publish to PyPI
23
+ uses: pypa/gh-action-pypi-publish@release/v1
24
+ with:
25
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ *.pyc
@@ -0,0 +1,96 @@
1
+ # Contributing to ferpa-haystack
2
+
3
+ Thank you for your interest in contributing. This project is a FERPA-compliant document filter for Haystack RAG pipelines, and we welcome contributions from the Haystack and higher-education AI community.
4
+
5
+ ## Ways to Contribute
6
+
7
+ - **Bug reports** — open an issue with a minimal reproduction case
8
+ - **Feature requests** — open an issue describing the use case and expected behavior
9
+ - **Pull requests** — bug fixes, new features, documentation improvements, additional test coverage
10
+ - **Peer review** — review open pull requests and leave feedback
11
+
12
+ ## Getting Started
13
+
14
+ ### Prerequisites
15
+
16
+ - Python 3.10, 3.11, or 3.12
17
+ - [Hatch](https://hatch.pypa.io/) for environment and test management
18
+
19
+ ```bash
20
+ pip install hatch
21
+ ```
22
+
23
+ ### Setup
24
+
25
+ ```bash
26
+ git clone https://github.com/ashutoshrana/ferpa-haystack.git
27
+ cd ferpa-haystack
28
+ hatch env create
29
+ ```
30
+
31
+ ### Run Tests
32
+
33
+ ```bash
34
+ hatch run test
35
+ ```
36
+
37
+ ### Run Linter
38
+
39
+ ```bash
40
+ hatch run lint
41
+ ```
42
+
43
+ All tests must pass and lint must be clean before a PR will be reviewed.
44
+
45
+ ## Project Structure
46
+
47
+ ```
48
+ src/
49
+ haystack_integrations/
50
+ components/
51
+ filters/
52
+ ferpa_filter/
53
+ __init__.py
54
+ __about__.py
55
+ ferpa_metadata_filter.py ← main component
56
+ tests/
57
+ test_ferpa_metadata_filter.py
58
+ examples/
59
+ basic_pipeline.py
60
+ ```
61
+
62
+ ## Submitting a Pull Request
63
+
64
+ 1. Fork the repository and create a branch from `main`.
65
+ 2. Make your changes with clear, focused commits.
66
+ 3. Add or update tests for any changed behavior.
67
+ 4. Ensure `hatch run test` and `hatch run lint` both pass locally.
68
+ 5. Open a pull request against `main` with a clear description of what changed and why.
69
+
70
+ ## Issue Labels
71
+
72
+ | Label | Meaning |
73
+ |-------|---------|
74
+ | `good first issue` | Beginner-friendly — no deep codebase knowledge needed |
75
+ | `help wanted` | Maintainer is actively seeking contributions |
76
+ | `bug` | Confirmed defect with documented behavior |
77
+ | `enhancement` | New feature or improvement to existing behavior |
78
+ | `documentation` | Docs-only change |
79
+
80
+ ## Code Style
81
+
82
+ - Line length: 120 characters (enforced by ruff)
83
+ - Type hints required on all public functions
84
+ - No comments explaining *what* code does — only *why* when non-obvious
85
+
86
+ ## Regulatory Context
87
+
88
+ This project enforces 34 CFR § 99 (FERPA). If you are adding or changing filtering logic, please reference the specific regulation section in your PR description. Incorrect filtering behavior is a compliance defect, not just a functional bug.
89
+
90
+ ## Code of Conduct
91
+
92
+ Be respectful and constructive. Maintainers will close issues or PRs that are dismissive, harassing, or off-topic without warning.
93
+
94
+ ## Questions
95
+
96
+ Open an issue with the `question` label or start a [GitHub Discussion](https://github.com/ashutoshrana/ferpa-haystack/discussions).
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Ashutosh Rana
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: ferpa-haystack
3
+ Version: 0.1.0
4
+ Summary: FERPA-compliant document filter for Haystack RAG pipelines — enforces identity-scoped access control before documents reach the LLM
5
+ Project-URL: Homepage, https://github.com/ashutoshrana/ferpa-haystack
6
+ Project-URL: Documentation, https://github.com/ashutoshrana/ferpa-haystack#readme
7
+ Project-URL: Bug Tracker, https://github.com/ashutoshrana/ferpa-haystack/issues
8
+ Project-URL: Source Code, https://github.com/ashutoshrana/ferpa-haystack
9
+ Author-email: Ashutosh Rana <ai.automate101@gmail.com>
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: AI-governance,FERPA,LLM,NLP,RAG,compliance,data-privacy,education,haystack,higher-education
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: haystack-ai>=2.0.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: hatch; extra == 'dev'
25
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # ferpa-haystack
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
33
+ [![Tests](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml/badge.svg)](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml)
34
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
35
+ [![Downloads](https://img.shields.io/pypi/dm/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
36
+
37
+ **FERPA-compliant document filtering for Haystack RAG pipelines.**
38
+
39
+ Enforces 34 CFR § 99 identity-scoped access control at the retrieval layer — before any document reaches the LLM context window.
40
+
41
+ ---
42
+
43
+ ## The Problem
44
+
45
+ Standard Haystack pipelines retrieve documents and pass them directly to the LLM with no enforcement of who is allowed to see what. In higher-education deployments, this creates a structural FERPA compliance gap: a student advising chatbot may return another student's academic record, financial aid details, or disciplinary history in response to a query.
46
+
47
+ This component closes that gap by adding a two-layer compliance filter between your retriever and your LLM.
48
+
49
+ ---
50
+
51
+ ## Architecture
52
+
53
+ ```
54
+ Haystack Pipeline
55
+
56
+
57
+ InMemoryEmbeddingRetriever (or any retriever)
58
+ │ documents (all retrieved)
59
+
60
+ FERPAMetadataFilter
61
+ │ Layer 1: Identity pre-filter (student_id + institution_id)
62
+ │ Layer 2: Category authorization (academic_record, financial_aid, ...)
63
+
64
+ ├── documents ──────────────► LLM (only authorized records)
65
+ └── disclosure_record ──────► Audit log (34 CFR § 99.32)
66
+ ```
67
+
68
+ **Documents without identity metadata** (course catalogues, policy handbooks) pass through both layers unchanged — shared knowledge-base content is never blocked.
69
+
70
+ ---
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ pip install ferpa-haystack
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Quick Start
81
+
82
+ ```python
83
+ from haystack import Pipeline
84
+ from haystack.components.generators import OpenAIGenerator
85
+ from haystack.components.retrievers import InMemoryEmbeddingRetriever
86
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
87
+ from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
88
+
89
+ doc_store = InMemoryDocumentStore()
90
+
91
+ ferpa_filter = FERPAMetadataFilter(
92
+ student_id="stu_001",
93
+ institution_id="univ_abc",
94
+ authorized_categories=["academic_record", "financial_aid"],
95
+ requesting_user_id="advisor_007",
96
+ )
97
+
98
+ pipeline = Pipeline()
99
+ pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
100
+ pipeline.add_component("ferpa_filter", ferpa_filter)
101
+ pipeline.add_component("llm", OpenAIGenerator(model="gpt-4o"))
102
+
103
+ pipeline.connect("retriever.documents", "ferpa_filter.documents")
104
+ pipeline.connect("ferpa_filter.documents", "llm.documents")
105
+
106
+ result = pipeline.run({"retriever": {"query_embedding": query_emb}})
107
+
108
+ # Only stu_001's authorized records reached the LLM
109
+ authorized_docs = result["ferpa_filter"]["documents"]
110
+
111
+ # 34 CFR § 99.32 audit entry — log this to your compliance system
112
+ audit_record = result["ferpa_filter"]["disclosure_record"]
113
+ print(audit_record.to_log_entry())
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Filtering Layers
119
+
120
+ ### Layer 1 — Identity Pre-Filter
121
+
122
+ Documents are matched against `student_id` and `institution_id` metadata fields.
123
+
124
+ | Document metadata | Outcome |
125
+ |-------------------|---------|
126
+ | No `student_id` or `institution_id` | **Pass** — treated as shared content |
127
+ | `student_id` matches | **Continue to Layer 2** |
128
+ | `student_id` does not match | **Blocked** |
129
+
130
+ ### Layer 2 — Category Authorization
131
+
132
+ When `authorized_categories` is non-empty, the document's `category` field must be in the authorized set.
133
+
134
+ ```python
135
+ # Only academic records and financial aid — disciplinary records are blocked
136
+ FERPAMetadataFilter(
137
+ student_id="stu_001",
138
+ institution_id="univ_abc",
139
+ authorized_categories=["academic_record", "financial_aid"],
140
+ # "disciplinary" is blocked even if identity matches
141
+ )
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Audit Record (34 CFR § 99.32)
147
+
148
+ Every call to `run()` produces a `FERPADisclosureRecord` regardless of how many documents are authorized:
149
+
150
+ ```python
151
+ @dataclass
152
+ class FERPADisclosureRecord:
153
+ student_id: str
154
+ institution_id: str
155
+ requesting_user_id: str
156
+ disclosed_at: datetime # UTC timestamp
157
+ total_retrieved: int # documents from retriever
158
+ total_disclosed: int # documents that passed filtering
159
+ categories_disclosed: list[str] # record categories in result
160
+ pipeline_context: str # pipeline/workflow label
161
+ ```
162
+
163
+ Log it to your compliance database:
164
+
165
+ ```python
166
+ import logging
167
+ compliance_logger = logging.getLogger("ferpa.audit")
168
+ compliance_logger.info(result["ferpa_filter"]["disclosure_record"].to_log_entry())
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Configuration
174
+
175
+ ```python
176
+ FERPAMetadataFilter(
177
+ student_id="stu_001",
178
+ institution_id="univ_abc",
179
+ authorized_categories=["academic_record"], # empty = all categories allowed
180
+ requesting_user_id="advisor_007", # recorded in audit log
181
+ student_id_field="student_id", # custom meta key
182
+ institution_id_field="institution_id", # custom meta key
183
+ category_field="category", # custom meta key
184
+ pipeline_context="advising_pipeline", # audit label
185
+ raise_on_violation=False, # True = raise PermissionError
186
+ )
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Custom Field Names
192
+
193
+ If your document store uses different metadata keys:
194
+
195
+ ```python
196
+ FERPAMetadataFilter(
197
+ student_id="stu_001",
198
+ institution_id="univ_abc",
199
+ student_id_field="learner_id", # your custom key
200
+ institution_id_field="campus_code", # your custom key
201
+ category_field="record_type", # your custom key
202
+ )
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Pipeline Serialization
208
+
209
+ The component is fully serializable for YAML/JSON pipeline storage:
210
+
211
+ ```python
212
+ pipeline.to_yaml("advising_pipeline.yaml")
213
+ pipeline_restored = Pipeline.from_yaml("advising_pipeline.yaml")
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Regulatory Basis
219
+
220
+ | Regulation | Section | What this component enforces |
221
+ |-----------|---------|------------------------------|
222
+ | FERPA | 34 CFR § 99.31(a)(1) | Legitimate educational interest — only authorized roles access records |
223
+ | FERPA | 34 CFR § 99.32 | Record of disclosures — structured audit entry on every access |
224
+
225
+ ---
226
+
227
+ ## Related Projects
228
+
229
+ - **[enterprise-rag-patterns](https://github.com/ashutoshrana/enterprise-rag-patterns)** — FERPA, HIPAA, GDPR compliance patterns for RAG across 50+ regulated sectors
230
+ - **[regulated-ai-governance](https://github.com/ashutoshrana/regulated-ai-governance)** — Policy enforcement for AI agents across 25 jurisdictions
231
+
232
+ ---
233
+
234
+ ## License
235
+
236
+ Apache License 2.0 — see [LICENSE](LICENSE)
@@ -0,0 +1,208 @@
1
+ # ferpa-haystack
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
5
+ [![Tests](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml/badge.svg)](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml)
6
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
7
+ [![Downloads](https://img.shields.io/pypi/dm/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
8
+
9
+ **FERPA-compliant document filtering for Haystack RAG pipelines.**
10
+
11
+ Enforces 34 CFR § 99 identity-scoped access control at the retrieval layer — before any document reaches the LLM context window.
12
+
13
+ ---
14
+
15
+ ## The Problem
16
+
17
+ Standard Haystack pipelines retrieve documents and pass them directly to the LLM with no enforcement of who is allowed to see what. In higher-education deployments, this creates a structural FERPA compliance gap: a student advising chatbot may return another student's academic record, financial aid details, or disciplinary history in response to a query.
18
+
19
+ This component closes that gap by adding a two-layer compliance filter between your retriever and your LLM.
20
+
21
+ ---
22
+
23
+ ## Architecture
24
+
25
+ ```
26
+ Haystack Pipeline
27
+
28
+
29
+ InMemoryEmbeddingRetriever (or any retriever)
30
+ │ documents (all retrieved)
31
+
32
+ FERPAMetadataFilter
33
+ │ Layer 1: Identity pre-filter (student_id + institution_id)
34
+ │ Layer 2: Category authorization (academic_record, financial_aid, ...)
35
+
36
+ ├── documents ──────────────► LLM (only authorized records)
37
+ └── disclosure_record ──────► Audit log (34 CFR § 99.32)
38
+ ```
39
+
40
+ **Documents without identity metadata** (course catalogues, policy handbooks) pass through both layers unchanged — shared knowledge-base content is never blocked.
41
+
42
+ ---
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ pip install ferpa-haystack
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Quick Start
53
+
54
+ ```python
55
+ from haystack import Pipeline
56
+ from haystack.components.generators import OpenAIGenerator
57
+ from haystack.components.retrievers import InMemoryEmbeddingRetriever
58
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
59
+ from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
60
+
61
+ doc_store = InMemoryDocumentStore()
62
+
63
+ ferpa_filter = FERPAMetadataFilter(
64
+ student_id="stu_001",
65
+ institution_id="univ_abc",
66
+ authorized_categories=["academic_record", "financial_aid"],
67
+ requesting_user_id="advisor_007",
68
+ )
69
+
70
+ pipeline = Pipeline()
71
+ pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
72
+ pipeline.add_component("ferpa_filter", ferpa_filter)
73
+ pipeline.add_component("llm", OpenAIGenerator(model="gpt-4o"))
74
+
75
+ pipeline.connect("retriever.documents", "ferpa_filter.documents")
76
+ pipeline.connect("ferpa_filter.documents", "llm.documents")
77
+
78
+ result = pipeline.run({"retriever": {"query_embedding": query_emb}})
79
+
80
+ # Only stu_001's authorized records reached the LLM
81
+ authorized_docs = result["ferpa_filter"]["documents"]
82
+
83
+ # 34 CFR § 99.32 audit entry — log this to your compliance system
84
+ audit_record = result["ferpa_filter"]["disclosure_record"]
85
+ print(audit_record.to_log_entry())
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Filtering Layers
91
+
92
+ ### Layer 1 — Identity Pre-Filter
93
+
94
+ Documents are matched against `student_id` and `institution_id` metadata fields.
95
+
96
+ | Document metadata | Outcome |
97
+ |-------------------|---------|
98
+ | No `student_id` or `institution_id` | **Pass** — treated as shared content |
99
+ | `student_id` matches | **Continue to Layer 2** |
100
+ | `student_id` does not match | **Blocked** |
101
+
102
+ ### Layer 2 — Category Authorization
103
+
104
+ When `authorized_categories` is non-empty, the document's `category` field must be in the authorized set.
105
+
106
+ ```python
107
+ # Only academic records and financial aid — disciplinary records are blocked
108
+ FERPAMetadataFilter(
109
+ student_id="stu_001",
110
+ institution_id="univ_abc",
111
+ authorized_categories=["academic_record", "financial_aid"],
112
+ # "disciplinary" is blocked even if identity matches
113
+ )
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Audit Record (34 CFR § 99.32)
119
+
120
+ Every call to `run()` produces a `FERPADisclosureRecord` regardless of how many documents are authorized:
121
+
122
+ ```python
123
+ @dataclass
124
+ class FERPADisclosureRecord:
125
+ student_id: str
126
+ institution_id: str
127
+ requesting_user_id: str
128
+ disclosed_at: datetime # UTC timestamp
129
+ total_retrieved: int # documents from retriever
130
+ total_disclosed: int # documents that passed filtering
131
+ categories_disclosed: list[str] # record categories in result
132
+ pipeline_context: str # pipeline/workflow label
133
+ ```
134
+
135
+ Log it to your compliance database:
136
+
137
+ ```python
138
+ import logging
139
+ compliance_logger = logging.getLogger("ferpa.audit")
140
+ compliance_logger.info(result["ferpa_filter"]["disclosure_record"].to_log_entry())
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Configuration
146
+
147
+ ```python
148
+ FERPAMetadataFilter(
149
+ student_id="stu_001",
150
+ institution_id="univ_abc",
151
+ authorized_categories=["academic_record"], # empty = all categories allowed
152
+ requesting_user_id="advisor_007", # recorded in audit log
153
+ student_id_field="student_id", # custom meta key
154
+ institution_id_field="institution_id", # custom meta key
155
+ category_field="category", # custom meta key
156
+ pipeline_context="advising_pipeline", # audit label
157
+ raise_on_violation=False, # True = raise PermissionError
158
+ )
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Custom Field Names
164
+
165
+ If your document store uses different metadata keys:
166
+
167
+ ```python
168
+ FERPAMetadataFilter(
169
+ student_id="stu_001",
170
+ institution_id="univ_abc",
171
+ student_id_field="learner_id", # your custom key
172
+ institution_id_field="campus_code", # your custom key
173
+ category_field="record_type", # your custom key
174
+ )
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Pipeline Serialization
180
+
181
+ The component is fully serializable for YAML/JSON pipeline storage:
182
+
183
+ ```python
184
+ pipeline.to_yaml("advising_pipeline.yaml")
185
+ pipeline_restored = Pipeline.from_yaml("advising_pipeline.yaml")
186
+ ```
187
+
188
+ ---
189
+
190
+ ## Regulatory Basis
191
+
192
+ | Regulation | Section | What this component enforces |
193
+ |-----------|---------|------------------------------|
194
+ | FERPA | 34 CFR § 99.31(a)(1) | Legitimate educational interest — only authorized roles access records |
195
+ | FERPA | 34 CFR § 99.32 | Record of disclosures — structured audit entry on every access |
196
+
197
+ ---
198
+
199
+ ## Related Projects
200
+
201
+ - **[enterprise-rag-patterns](https://github.com/ashutoshrana/enterprise-rag-patterns)** — FERPA, HIPAA, GDPR compliance patterns for RAG across 50+ regulated sectors
202
+ - **[regulated-ai-governance](https://github.com/ashutoshrana/regulated-ai-governance)** — Policy enforcement for AI agents across 25 jurisdictions
203
+
204
+ ---
205
+
206
+ ## License
207
+
208
+ Apache License 2.0 — see [LICENSE](LICENSE)
@@ -0,0 +1,94 @@
1
+ """
2
+ Basic usage of ferpa-haystack.
3
+
4
+ Shows how to add FERPA-compliant filtering to a Haystack RAG pipeline.
5
+
6
+ Install: pip install ferpa-haystack haystack-ai
7
+ """
8
+
9
+ from haystack import Document, Pipeline
10
+ from haystack.components.generators import OpenAIGenerator
11
+ from haystack.components.builders import PromptBuilder
12
+ from haystack.components.retrievers import InMemoryEmbeddingRetriever
13
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
14
+
15
+ from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
16
+
17
+ # Build a sample document store with identity-tagged records
18
+ doc_store = InMemoryDocumentStore()
19
+ doc_store.write_documents([
20
+ # Student stu_001's records
21
+ Document(
22
+ content="Alice Johnson — GPA: 3.85, Major: Computer Science, Credits: 92/120",
23
+ meta={"student_id": "stu_001", "institution_id": "univ_abc", "category": "academic_record"},
24
+ ),
25
+ Document(
26
+ content="Alice Johnson — Financial Aid Award 2025: $15,000 Pell Grant + $8,500 Subsidized Loan",
27
+ meta={"student_id": "stu_001", "institution_id": "univ_abc", "category": "financial_aid"},
28
+ ),
29
+ # Another student's record — should be blocked
30
+ Document(
31
+ content="Bob Smith — GPA: 2.9, Academic Probation Notice",
32
+ meta={"student_id": "stu_002", "institution_id": "univ_abc", "category": "academic_record"},
33
+ ),
34
+ # Shared knowledge base — no identity metadata — always passes through
35
+ Document(
36
+ content="University Graduation Requirements: 120 credits, minimum 2.0 GPA",
37
+ meta={},
38
+ ),
39
+ ])
40
+
41
+ # FERPA filter — only Alice's academic and financial aid records pass through
42
+ ferpa_filter = FERPAMetadataFilter(
43
+ student_id="stu_001",
44
+ institution_id="univ_abc",
45
+ authorized_categories=["academic_record", "financial_aid"],
46
+ requesting_user_id="advisor_007",
47
+ pipeline_context="student_advising_chatbot",
48
+ )
49
+
50
+ # Build pipeline — retriever → FERPA filter → LLM
51
+ pipeline = Pipeline()
52
+ pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
53
+ pipeline.add_component("ferpa_filter", ferpa_filter)
54
+ pipeline.add_component(
55
+ "prompt",
56
+ PromptBuilder(template="Answer using only the provided student records:\n{% for doc in documents %}{{ doc.content }}\n{% endfor %}\n\nQuestion: {{ question }}")
57
+ )
58
+ pipeline.add_component("llm", OpenAIGenerator(model="gpt-4o-mini"))
59
+
60
+ pipeline.connect("retriever.documents", "ferpa_filter.documents")
61
+ pipeline.connect("ferpa_filter.documents", "prompt.documents")
62
+ pipeline.connect("prompt.prompt", "llm.prompt")
63
+
64
+ # The FERPA filter ensures Bob's record never reaches the LLM
65
+ # result = pipeline.run({
66
+ # "retriever": {"query_embedding": embed("What is Alice's GPA?")},
67
+ # "prompt": {"question": "What is the student's GPA?"},
68
+ # })
69
+
70
+ # Standalone usage (no embedding needed for this demo)
71
+ sample_docs = [
72
+ Document(
73
+ content="Alice — GPA 3.85",
74
+ meta={"student_id": "stu_001", "institution_id": "univ_abc", "category": "academic_record"},
75
+ ),
76
+ Document(
77
+ content="Bob — GPA 2.9", # different student
78
+ meta={"student_id": "stu_002", "institution_id": "univ_abc", "category": "academic_record"},
79
+ ),
80
+ Document(
81
+ content="Graduation requires 120 credits", # shared content
82
+ meta={},
83
+ ),
84
+ ]
85
+
86
+ result = ferpa_filter.run(sample_docs)
87
+ print(f"Input: {len(sample_docs)} documents")
88
+ print(f"After FERPA filter: {len(result['documents'])} documents")
89
+ print(f"Audit log: {result['disclosure_record'].to_log_entry()}")
90
+
91
+ # Expected output:
92
+ # Input: 3 documents
93
+ # After FERPA filter: 2 documents (Alice's record + shared graduation info)
94
+ # Audit log: [FERPA_DISCLOSURE] student_id='stu_001' ... total_retrieved=3 total_disclosed=2
@@ -0,0 +1,95 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ferpa-haystack"
7
+ dynamic = ["version"]
8
+ description = "FERPA-compliant document filter for Haystack RAG pipelines — enforces identity-scoped access control before documents reach the LLM"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Ashutosh Rana", email = "ai.automate101@gmail.com" },
14
+ ]
15
+ keywords = [
16
+ "haystack",
17
+ "RAG",
18
+ "FERPA",
19
+ "compliance",
20
+ "education",
21
+ "NLP",
22
+ "LLM",
23
+ "data-privacy",
24
+ "higher-education",
25
+ "AI-governance",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 4 - Beta",
29
+ "Intended Audience :: Developers",
30
+ "License :: OSI Approved :: Apache Software License",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
36
+ ]
37
+ dependencies = [
38
+ "haystack-ai>=2.0.0",
39
+ ]
40
+
41
+ [project.optional-dependencies]
42
+ dev = [
43
+ "pytest>=7.0",
44
+ "pytest-asyncio>=0.21",
45
+ "hatch",
46
+ ]
47
+
48
+ [project.urls]
49
+ Homepage = "https://github.com/ashutoshrana/ferpa-haystack"
50
+ Documentation = "https://github.com/ashutoshrana/ferpa-haystack#readme"
51
+ "Bug Tracker" = "https://github.com/ashutoshrana/ferpa-haystack/issues"
52
+ "Source Code" = "https://github.com/ashutoshrana/ferpa-haystack"
53
+
54
+ [tool.hatch.version]
55
+ path = "src/haystack_integrations/components/filters/ferpa_filter/__about__.py"
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["src/haystack_integrations"]
59
+
60
+ [tool.hatch.envs.default]
61
+ dependencies = [
62
+ "coverage[toml]>=6.5",
63
+ "pytest>=7.0",
64
+ "pytest-asyncio>=0.21",
65
+ "haystack-ai>=2.0.0",
66
+ "ruff>=0.4.0",
67
+ ]
68
+
69
+ [tool.hatch.envs.default.scripts]
70
+ test = "pytest {args:tests}"
71
+ test-cov = "coverage run -m pytest {args:tests}"
72
+ cov-report = ["coverage report"]
73
+ fmt = ["ruff format {args:.}", "ruff check --fix {args:.}"]
74
+ lint = ["ruff check {args:.}"]
75
+
76
+ [tool.hatch.envs.test]
77
+ [[tool.hatch.envs.test.matrix]]
78
+ python = ["3.10", "3.11", "3.12"]
79
+
80
+ [tool.ruff]
81
+ line-length = 120
82
+ target-version = "py310"
83
+
84
+ [tool.ruff.lint]
85
+ select = ["E", "F", "W"]
86
+ ignore = [
87
+ "E501", # line too long — handled by formatter
88
+ ]
89
+
90
+ [tool.ruff.lint.per-file-ignores]
91
+ "examples/*" = ["E402", "F401", "F811"]
92
+ "tests/*" = ["S101"]
93
+
94
+ [tool.pytest.ini_options]
95
+ asyncio_mode = "auto"
@@ -0,0 +1,6 @@
1
+ from haystack_integrations.components.filters.ferpa_filter.ferpa_metadata_filter import (
2
+ FERPADisclosureRecord,
3
+ FERPAMetadataFilter,
4
+ )
5
+
6
+ __all__ = ["FERPADisclosureRecord", "FERPAMetadataFilter"]
@@ -0,0 +1,250 @@
1
+ """
2
+ FERPAMetadataFilter — FERPA-compliant document filter for Haystack RAG pipelines.
3
+
4
+ Enforces identity-scoped access control on retriever results before they reach
5
+ the LLM context window. Complies with 34 CFR § 99.31(a)(1) (legitimate educational
6
+ interest) and § 99.32 (record of disclosures).
7
+
8
+ Two filtering layers applied in sequence:
9
+
10
+ 1. Identity pre-filter — removes documents whose student_id or institution_id
11
+ metadata does not match the authorized scope.
12
+ 2. Category authorization — removes documents whose category is not in the
13
+ authorized set (e.g., only ACADEMIC_RECORD, not DISCIPLINARY).
14
+
15
+ Documents with no identity metadata are treated as shared knowledge-base content
16
+ (course catalogues, policy handbooks) and pass through unchanged.
17
+
18
+ Usage::
19
+
20
+ from haystack import Pipeline
21
+ from haystack.components.retrievers import InMemoryEmbeddingRetriever
22
+ from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
23
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
24
+
25
+ ferpa_filter = FERPAMetadataFilter(
26
+ student_id="stu_001",
27
+ institution_id="inst_abc",
28
+ authorized_categories=["academic_record", "financial_aid"],
29
+ requesting_user_id="advisor_007",
30
+ )
31
+
32
+ pipeline = Pipeline()
33
+ pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
34
+ pipeline.add_component("ferpa_filter", ferpa_filter)
35
+ pipeline.connect("retriever.documents", "ferpa_filter.documents")
36
+
37
+ result = pipeline.run({"retriever": {"query_embedding": query_emb}})
38
+ # result["ferpa_filter"]["documents"] — only stu_001's authorized records
39
+ # result["ferpa_filter"]["disclosure_record"] — 34 CFR § 99.32 audit entry
40
+
41
+ Regulatory basis:
42
+ 34 CFR § 99.31(a)(1) — legitimate educational interest
43
+ 34 CFR § 99.32 — record of disclosures
44
+ """
45
+
46
+ import logging
47
+ from dataclasses import dataclass, field
48
+ from datetime import datetime, timezone
49
+ from typing import Any
50
+
51
+ from haystack import Document, component, default_from_dict, default_to_dict
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ _SENTINEL = object()
56
+
57
+
58
+ @dataclass
59
+ class FERPADisclosureRecord:
60
+ """
61
+ Structured audit record of a FERPA disclosure event (34 CFR § 99.32).
62
+
63
+ Attributes:
64
+ student_id: Identifier of the student whose records were accessed.
65
+ institution_id: Identifier of the institution.
66
+ requesting_user_id: User or system that requested access.
67
+ disclosed_at: UTC timestamp of the disclosure.
68
+ total_retrieved: Documents returned by the retriever before filtering.
69
+ total_disclosed: Documents that passed FERPA filtering.
70
+ categories_disclosed: Record categories included in the result.
71
+ pipeline_context: Label identifying the pipeline or workflow context.
72
+ """
73
+
74
+ student_id: str
75
+ institution_id: str
76
+ requesting_user_id: str
77
+ disclosed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
78
+ total_retrieved: int = 0
79
+ total_disclosed: int = 0
80
+ categories_disclosed: list[str] = field(default_factory=list)
81
+ pipeline_context: str = "haystack_pipeline"
82
+
83
+ def to_log_entry(self) -> str:
84
+ return (
85
+ f"[FERPA_DISCLOSURE] student_id={self.student_id!r} "
86
+ f"institution_id={self.institution_id!r} "
87
+ f"requesting_user_id={self.requesting_user_id!r} "
88
+ f"disclosed_at={self.disclosed_at.isoformat()} "
89
+ f"total_retrieved={self.total_retrieved} "
90
+ f"total_disclosed={self.total_disclosed} "
91
+ f"categories_disclosed={self.categories_disclosed!r} "
92
+ f"pipeline_context={self.pipeline_context!r}"
93
+ )
94
+
95
+
96
+ @component
97
+ class FERPAMetadataFilter:
98
+ """
99
+ Haystack component that enforces FERPA identity-scope filtering on retrieved
100
+ documents before they enter the LLM context window.
101
+
102
+ Connects to any retriever output and emits only the documents that fall within
103
+ the authorized identity scope. Always emits a FERPADisclosureRecord for
104
+ downstream compliance logging (34 CFR § 99.32).
105
+
106
+ Two enforcement layers:
107
+
108
+ 1. Identity pre-filter: student_id and institution_id in Document.meta must
109
+ match the authorized scope. Documents with neither field are shared content
110
+ and pass through unchanged.
111
+
112
+ 2. Category authorization: when authorized_categories is non-empty, the
113
+ document's category field must be in the authorized set.
114
+
115
+ Args:
116
+ student_id: Authorized student identifier.
117
+ institution_id: Authorized institution identifier.
118
+ authorized_categories: Permitted record category strings.
119
+ Empty list means all categories are allowed.
120
+ requesting_user_id: Identifier of the requesting user (for audit log).
121
+ student_id_field: Meta key for student identifier. Default: "student_id".
122
+ institution_id_field: Meta key for institution identifier. Default: "institution_id".
123
+ category_field: Meta key for record category. Default: "category".
124
+ pipeline_context: Label for the audit record. Default: "haystack_pipeline".
125
+ raise_on_violation: When True, raise PermissionError on unauthorized docs.
126
+ When False (default), silently remove and emit WARNING.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ student_id: str,
132
+ institution_id: str,
133
+ authorized_categories: list[str] | None = None,
134
+ requesting_user_id: str = "unknown",
135
+ student_id_field: str = "student_id",
136
+ institution_id_field: str = "institution_id",
137
+ category_field: str = "category",
138
+ pipeline_context: str = "haystack_pipeline",
139
+ raise_on_violation: bool = False,
140
+ ) -> None:
141
+ self.student_id = student_id
142
+ self.institution_id = institution_id
143
+ self.authorized_categories = list(authorized_categories) if authorized_categories else []
144
+ self.requesting_user_id = requesting_user_id
145
+ self.student_id_field = student_id_field
146
+ self.institution_id_field = institution_id_field
147
+ self.category_field = category_field
148
+ self.pipeline_context = pipeline_context
149
+ self.raise_on_violation = raise_on_violation
150
+
151
+ def to_dict(self) -> dict[str, Any]:
152
+ return default_to_dict(
153
+ self,
154
+ student_id=self.student_id,
155
+ institution_id=self.institution_id,
156
+ authorized_categories=self.authorized_categories,
157
+ requesting_user_id=self.requesting_user_id,
158
+ student_id_field=self.student_id_field,
159
+ institution_id_field=self.institution_id_field,
160
+ category_field=self.category_field,
161
+ pipeline_context=self.pipeline_context,
162
+ raise_on_violation=self.raise_on_violation,
163
+ )
164
+
165
+ @classmethod
166
+ def from_dict(cls, data: dict[str, Any]) -> "FERPAMetadataFilter":
167
+ return default_from_dict(cls, data)
168
+
169
+ @component.output_types(documents=list[Document], disclosure_record=FERPADisclosureRecord)
170
+ def run(self, documents: list[Document]) -> dict[str, Any]:
171
+ """
172
+ Filter documents to the authorized identity scope.
173
+
174
+ Args:
175
+ documents: Documents from an upstream retriever.
176
+
177
+ Returns:
178
+ documents: Authorized documents only.
179
+ disclosure_record: FERPADisclosureRecord for compliance logging.
180
+
181
+ Raises:
182
+ PermissionError: Only when raise_on_violation=True and unauthorized
183
+ documents were detected.
184
+ """
185
+ total_retrieved = len(documents)
186
+ authorized: list[Document] = []
187
+
188
+ for doc in documents:
189
+ if self._is_authorized(doc):
190
+ authorized.append(doc)
191
+
192
+ removed = total_retrieved - len(authorized)
193
+
194
+ if removed > 0:
195
+ if self.raise_on_violation:
196
+ raise PermissionError(
197
+ f"FERPA violation: {removed} unauthorized document(s) blocked for "
198
+ f"student={self.student_id!r}, institution={self.institution_id!r}."
199
+ )
200
+ logger.warning(
201
+ "[FERPA_FILTER] Blocked %d unauthorized document(s) student_id=%r institution_id=%r",
202
+ removed, self.student_id, self.institution_id,
203
+ )
204
+
205
+ record = FERPADisclosureRecord(
206
+ student_id=self.student_id,
207
+ institution_id=self.institution_id,
208
+ requesting_user_id=self.requesting_user_id,
209
+ total_retrieved=total_retrieved,
210
+ total_disclosed=len(authorized),
211
+ categories_disclosed=self._extract_categories(authorized),
212
+ pipeline_context=self.pipeline_context,
213
+ )
214
+ logger.info(record.to_log_entry())
215
+ return {"documents": authorized, "disclosure_record": record}
216
+
217
+ @component.output_types(documents=list[Document], disclosure_record=FERPADisclosureRecord)
218
+ async def run_async(self, documents: list[Document]) -> dict[str, Any]:
219
+ """Async variant of run — filtering is CPU-bound, runs synchronously."""
220
+ return self.run(documents)
221
+
222
+ def _is_authorized(self, doc: Document) -> bool:
223
+ meta = doc.meta or {}
224
+ doc_student_id = meta.get(self.student_id_field, _SENTINEL)
225
+ doc_institution_id = meta.get(self.institution_id_field, _SENTINEL)
226
+
227
+ # Shared content (no identity metadata) passes through
228
+ if doc_student_id is _SENTINEL and doc_institution_id is _SENTINEL:
229
+ return True
230
+
231
+ if doc_student_id != self.student_id:
232
+ return False
233
+ if doc_institution_id is not _SENTINEL and doc_institution_id != self.institution_id:
234
+ return False
235
+
236
+ if self.authorized_categories:
237
+ doc_category = meta.get(self.category_field, _SENTINEL)
238
+ if doc_category is not _SENTINEL and doc_category not in self.authorized_categories:
239
+ return False
240
+
241
+ return True
242
+
243
+ def _extract_categories(self, documents: list[Document]) -> list[str]:
244
+ categories: set[str] = set()
245
+ for doc in documents:
246
+ meta = doc.meta or {}
247
+ cat = meta.get(self.category_field)
248
+ if cat is not None:
249
+ categories.add(str(cat))
250
+ return sorted(categories)
@@ -0,0 +1,219 @@
1
+ """Tests for FERPAMetadataFilter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from haystack import Document
7
+ from haystack_integrations.components.filters.ferpa_filter import (
8
+ FERPADisclosureRecord,
9
+ FERPAMetadataFilter,
10
+ )
11
+
12
+
13
+ @pytest.fixture()
14
+ def default_filter() -> FERPAMetadataFilter:
15
+ return FERPAMetadataFilter(
16
+ student_id="stu_001",
17
+ institution_id="inst_abc",
18
+ authorized_categories=["academic_record", "financial_aid"],
19
+ requesting_user_id="advisor_007",
20
+ )
21
+
22
+
23
+ @pytest.fixture()
24
+ def authorized_doc() -> Document:
25
+ return Document(
26
+ content="GPA: 3.8",
27
+ meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "academic_record"},
28
+ )
29
+
30
+
31
+ @pytest.fixture()
32
+ def wrong_student_doc() -> Document:
33
+ return Document(
34
+ content="GPA: 3.5",
35
+ meta={"student_id": "stu_002", "institution_id": "inst_abc", "category": "academic_record"},
36
+ )
37
+
38
+
39
+ @pytest.fixture()
40
+ def shared_doc() -> Document:
41
+ return Document(content="Course Catalogue 2025", meta={})
42
+
43
+
44
+ class TestIdentityFilter:
45
+ def test_authorized_doc_passes(self, default_filter, authorized_doc):
46
+ result = default_filter.run([authorized_doc])
47
+ assert len(result["documents"]) == 1
48
+
49
+ def test_wrong_student_blocked(self, default_filter, wrong_student_doc):
50
+ result = default_filter.run([wrong_student_doc])
51
+ assert len(result["documents"]) == 0
52
+
53
+ def test_shared_content_passes(self, default_filter, shared_doc):
54
+ result = default_filter.run([shared_doc])
55
+ assert len(result["documents"]) == 1
56
+
57
+ def test_mixed_batch(self, default_filter, authorized_doc, wrong_student_doc, shared_doc):
58
+ result = default_filter.run([authorized_doc, wrong_student_doc, shared_doc])
59
+ assert len(result["documents"]) == 2
60
+
61
+ def test_wrong_institution_blocked(self, default_filter):
62
+ doc = Document(
63
+ content="...",
64
+ meta={"student_id": "stu_001", "institution_id": "inst_xyz", "category": "academic_record"},
65
+ )
66
+ result = default_filter.run([doc])
67
+ assert len(result["documents"]) == 0
68
+
69
+ def test_empty_input(self, default_filter):
70
+ result = default_filter.run([])
71
+ assert result["documents"] == []
72
+
73
+
74
+ class TestCategoryAuthorization:
75
+ def test_authorized_category_passes(self, default_filter):
76
+ doc = Document(
77
+ content="...",
78
+ meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "financial_aid"},
79
+ )
80
+ assert len(default_filter.run([doc])["documents"]) == 1
81
+
82
+ def test_unauthorized_category_blocked(self, default_filter):
83
+ doc = Document(
84
+ content="...",
85
+ meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "disciplinary"},
86
+ )
87
+ assert len(default_filter.run([doc])["documents"]) == 0
88
+
89
+ def test_no_category_field_passes(self, default_filter):
90
+ doc = Document(
91
+ content="...",
92
+ meta={"student_id": "stu_001", "institution_id": "inst_abc"},
93
+ )
94
+ assert len(default_filter.run([doc])["documents"]) == 1
95
+
96
+ def test_empty_authorized_categories_allows_all(self):
97
+ f = FERPAMetadataFilter(
98
+ student_id="stu_001",
99
+ institution_id="inst_abc",
100
+ authorized_categories=[],
101
+ )
102
+ doc = Document(
103
+ content="...",
104
+ meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "disciplinary"},
105
+ )
106
+ assert len(f.run([doc])["documents"]) == 1
107
+
108
+
109
+ class TestDisclosureRecord:
110
+ def test_record_always_present(self, default_filter, authorized_doc):
111
+ result = default_filter.run([authorized_doc])
112
+ assert "disclosure_record" in result
113
+ assert isinstance(result["disclosure_record"], FERPADisclosureRecord)
114
+
115
+ def test_record_counts(self, default_filter, authorized_doc, wrong_student_doc):
116
+ result = default_filter.run([authorized_doc, wrong_student_doc])
117
+ record = result["disclosure_record"]
118
+ assert record.total_retrieved == 2
119
+ assert record.total_disclosed == 1
120
+
121
+ def test_record_on_empty_input(self, default_filter):
122
+ result = default_filter.run([])
123
+ record = result["disclosure_record"]
124
+ assert record.total_retrieved == 0
125
+ assert record.total_disclosed == 0
126
+
127
+ def test_categories_disclosed(self, default_filter):
128
+ docs = [
129
+ Document(content="a", meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "academic_record"}),
130
+ Document(content="b", meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "financial_aid"}),
131
+ ]
132
+ record = default_filter.run(docs)["disclosure_record"]
133
+ assert set(record.categories_disclosed) == {"academic_record", "financial_aid"}
134
+
135
+ def test_log_entry_format(self, default_filter, authorized_doc):
136
+ record = default_filter.run([authorized_doc])["disclosure_record"]
137
+ log = record.to_log_entry()
138
+ assert "[FERPA_DISCLOSURE]" in log
139
+ assert "student_id=" in log
140
+ assert "total_disclosed=" in log
141
+
142
+
143
+ class TestRaiseOnViolation:
144
+ def test_raises_on_unauthorized(self, wrong_student_doc):
145
+ f = FERPAMetadataFilter(
146
+ student_id="stu_001",
147
+ institution_id="inst_abc",
148
+ raise_on_violation=True,
149
+ )
150
+ with pytest.raises(PermissionError, match="FERPA violation"):
151
+ f.run([wrong_student_doc])
152
+
153
+ def test_no_raise_when_all_authorized(self, authorized_doc):
154
+ f = FERPAMetadataFilter(
155
+ student_id="stu_001",
156
+ institution_id="inst_abc",
157
+ raise_on_violation=True,
158
+ )
159
+ result = f.run([authorized_doc])
160
+ assert len(result["documents"]) == 1
161
+
162
+
163
+ class TestSerialization:
164
+ def test_to_dict_round_trip(self, default_filter):
165
+ d = default_filter.to_dict()
166
+ restored = FERPAMetadataFilter.from_dict(d)
167
+ assert restored.student_id == default_filter.student_id
168
+ assert restored.institution_id == default_filter.institution_id
169
+ assert restored.authorized_categories == default_filter.authorized_categories
170
+ assert restored.requesting_user_id == default_filter.requesting_user_id
171
+
172
+ def test_from_dict_preserves_all_fields(self):
173
+ original = FERPAMetadataFilter(
174
+ student_id="s1",
175
+ institution_id="i1",
176
+ authorized_categories=["academic_record"],
177
+ requesting_user_id="advisor_1",
178
+ student_id_field="learner_id",
179
+ institution_id_field="campus_code",
180
+ category_field="record_type",
181
+ pipeline_context="test_pipeline",
182
+ raise_on_violation=True,
183
+ )
184
+ restored = FERPAMetadataFilter.from_dict(original.to_dict())
185
+ assert restored.student_id_field == "learner_id"
186
+ assert restored.institution_id_field == "campus_code"
187
+ assert restored.category_field == "record_type"
188
+ assert restored.raise_on_violation is True
189
+
190
+
191
+ class TestCustomFieldNames:
192
+ def test_custom_student_field(self):
193
+ f = FERPAMetadataFilter(
194
+ student_id="s1",
195
+ institution_id="i1",
196
+ student_id_field="learner_id",
197
+ institution_id_field="campus_code",
198
+ )
199
+ doc = Document(content="...", meta={"learner_id": "s1", "campus_code": "i1"})
200
+ assert len(f.run([doc])["documents"]) == 1
201
+
202
+ def test_custom_category_field(self):
203
+ f = FERPAMetadataFilter(
204
+ student_id="s1",
205
+ institution_id="i1",
206
+ authorized_categories=["transcript"],
207
+ category_field="record_type",
208
+ )
209
+ doc = Document(content="...", meta={"student_id": "s1", "institution_id": "i1", "record_type": "transcript"})
210
+ assert len(f.run([doc])["documents"]) == 1
211
+
212
+
213
+ class TestAsync:
214
+ @pytest.mark.asyncio
215
+ async def test_run_async_matches_run(self, default_filter, authorized_doc, wrong_student_doc):
216
+ sync_result = default_filter.run([authorized_doc, wrong_student_doc])
217
+ async_result = await default_filter.run_async([authorized_doc, wrong_student_doc])
218
+ assert len(sync_result["documents"]) == len(async_result["documents"])
219
+ assert sync_result["disclosure_record"].total_disclosed == async_result["disclosure_record"].total_disclosed