Phase 0 milestone 0.5: Registrant vertical slice

Registrant model + schemas + router + migration 0003, exercising the
vendored framework end-to-end (CRUDService, RBAC, audit, version history).

- SSN app-layer encrypted (AES-256-GCM, HKDF key, column-bound AAD,
  versioned format) in security/column_crypto.py; write-only on the model;
  serializer exposes only ssn_masked (XXX-XX-1234). C.5.7.50 / SC-28.
- pii_read audit on GET, list (one row per page with ids), and history.
  C.5.6.9 #5 / AU-14.
- Portal-only callers (YOUTH/PARENT/HOST) limited to own row via
  Registrant.user_id; out-of-scope is 404. PARENT_USER sees nothing until
  Guardian lands. History gated on VersionHistory, not View.
- minor_flag derived from dob at read time.

Framework changes (flag to OWC on re-vendor):
- crud._to_dict honors __audit_redact__, keeping ciphertext out of
  audit_log and version_history.
- crud._to_dict serializes date (not only datetime) for JSON columns.
- CRUDService.history annotation uses builtins.list (method shadowed it).

98 tests passing, 94.83% coverage; ruff + mypy clean.
Coverage trace at docs/coverage/registrant.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris TonjesandClaude Opus 5.5 committed 2026-09-30 19:25:50 -04:00
1 parent 185b5d2af3
commit 5dd828f0e5
15 files changed
+1098 -3

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
"""Registrant request/response schemas.
The read schema has no `ssn` field — only `ssn_masked` (``XXX-XX-1234``).
Masking happens here in the serializer, not the UI, so every API consumer sees
the masked form (ARCHITECTURE.md §6.3; source RFP `C.5.7.50`).
`user_id` is deliberately absent from the write schemas: linking a registrant
to a portal account is a Phase 1 identity flow, not a PATCH.
"""
from __future__ import annotations
import re
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
_ZIP = r"^\d{5}(-\d{4})?$"
def normalize_ssn(value: str | None) -> str | None:
"""Accept ``123-45-6789`` or ``123456789``; return 9 digits.
Rejects numbers the SSA never issues: area 000, 666, or 900–999; group 00;
serial 0000. Error messages never echo the input.
"""
if value is None:
return None
digits = re.sub(r"[\s-]", "", value)
if not re.fullmatch(r"\d{9}", digits):
raise ValueError("SSN must be 9 digits")
area, group, serial = digits[:3], digits[3:5], digits[5:]
if area in {"000", "666"} or area >= "900" or group == "00" or serial == "0000":
raise ValueError("SSN is not a valid issued number")
return digits
def _not_future(value: date | None) -> date | None:
if value is not None and value > date.today():
raise ValueError("dob cannot be in the future")
return value
class RegistrantCreate(BaseModel):
first_name: str = Field(min_length=1, max_length=100)
middle_name: str | None = Field(default=None, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
dob: date
ssn: str | None = None
email: EmailStr | None = None
phone: str | None = Field(default=None, max_length=32)
address_line1: str | None = Field(default=None, max_length=255)
address_line2: str | None = Field(default=None, max_length=255)
city: str | None = Field(default=None, max_length=100)
state: str | None = Field(default=None, min_length=2, max_length=2)
zip: str | None = Field(default=None, pattern=_ZIP)
_ssn = field_validator("ssn")(normalize_ssn)
_dob = field_validator("dob")(_not_future)
class RegistrantUpdate(BaseModel):
first_name: str | None = Field(default=None, min_length=1, max_length=100)
middle_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, min_length=1, max_length=100)
dob: date | None = None
ssn: str | None = None
email: EmailStr | None = None
phone: str | None = Field(default=None, max_length=32)
address_line1: str | None = Field(default=None, max_length=255)
address_line2: str | None = Field(default=None, max_length=255)
city: str | None = Field(default=None, max_length=100)
state: str | None = Field(default=None, min_length=2, max_length=2)
zip: str | None = Field(default=None, pattern=_ZIP)
_ssn = field_validator("ssn")(normalize_ssn)
_dob = field_validator("dob")(_not_future)
@field_validator("first_name", "last_name", "dob")
@classmethod
def _required_not_null(cls, v: object) -> object:
# Omit to leave unchanged; explicit null would violate NOT NULL.
if v is None:
raise ValueError("cannot be null")
return v
class RegistrantRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
first_name: str
middle_name: str | None
last_name: str
dob: date
ssn_masked: str | None
minor_flag: bool
email: str | None
phone: str | None
address_line1: str | None
address_line2: str | None
city: str | None
state: str | None
zip: str | None
version: int
created_at: datetime
updated_at: datetime
deleted_at: datetime | None