Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The v2 policy routes (`GET /v2/policies/{policy_id}`, `GET /v2/policies` and `POST /v2/policies`) now return `missing_parameters` with each policy: the parameter paths it sets that the catalog of the running PolicyEngine.py version no longer has, or null when that catalog is not published. `GET /{country}/policy/{policy_id}` returns the same field, checked against the country model the Public API runs; it reads nothing from Supabase.
4 changes: 2 additions & 2 deletions docs/engineering/migration-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi

| Method | Path | Status | Route group | Required headers | Stable response fields |
| --- | --- | ---: | --- | --- | --- |
| `POST` | `/v2/policies?country_id=us` | 201 | `policy` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at` |
| `GET` | `/v2/policies/{policy_id}?country_id=us` | 200 | `policy` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at` |
| `POST` | `/v2/policies?country_id=us` | 201 | `policy` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at`, `result.item.missing_parameters` |
| `GET` | `/v2/policies/{policy_id}?country_id=us` | 200 | `policy` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at`, `result.item.missing_parameters` |
| `GET` | `/v2/policies?country_id=us` | 200 | `policy` | none | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` |

### `saved_policy_v1_compatibility`
Expand Down
6 changes: 4 additions & 2 deletions docs/generated/migration_contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@
"result.item.tax_benefit_model_id",
"result.item.parameter_values",
"result.item.created_at",
"result.item.updated_at"
"result.item.updated_at",
"result.item.missing_parameters"
]
},
{
Expand All @@ -183,7 +184,8 @@
"result.item.tax_benefit_model_id",
"result.item.parameter_values",
"result.item.created_at",
"result.item.updated_at"
"result.item.updated_at",
"result.item.missing_parameters"
]
},
{
Expand Down
22 changes: 22 additions & 0 deletions docs/migration/stage-10-v2-policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,28 @@ request headers used by v1 and v2, exposes `X-PolicyEngine-Request-Id`, and
applies CORS headers to typed errors and service-unavailable responses. Route
functions do not implement separate preflight behavior.

## Missing parameters

A policy can set parameters that a later country package release renamed or
removed. Policy reads report them as `missing_parameters`, so a client can
tell its user and not run the policy:

- `GET /{country}/policy/{policy_id}` checks the v1 policy's parameter paths
against the leaf parameters of the country model the Public API runs. It
reads nothing from Supabase, so v1 reads stay on Cloud SQL. The field is
null for a stored policy that is not a parameter-path object.
- `GET /v2/policies/{policy_id}`, `GET /v2/policies` and `POST /v2/policies`
check each policy against the catalog of the running PolicyEngine.py
version. The field is null when that catalog is not published.

Both use the comparison the v2 copy uses (`parameters_missing_from_model`),
and the v2 catalog of a release holds the same leaf parameters as that
release's country model. So a v1 policy reported with missing parameters
is one a first copy under the running release refuses as
`missing_parameters`. A policy copied before a release removed its
parameter keeps that copy, since an existing copy is not translated again;
it is still reported as missing on both routes.

## Monitoring

Monitor structured events with metric names `v1_policy_mirror_operations` and
Expand Down
14 changes: 14 additions & 0 deletions policyengine_api/country.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
from dataclasses import dataclass
from datetime import date, datetime
from functools import cached_property
from importlib.metadata import version as get_package_version
from pathlib import Path
from typing import Any, NamedTuple
Expand Down Expand Up @@ -149,6 +150,19 @@ def __init__(self, country_package_name: str, country_id: str) -> None:
)
self.build_metadata()

@cached_property
def parameter_names(self) -> frozenset[str]:
"""Return the path of every leaf parameter, the parameters a policy can set.

Nodes (which hold parameters) and the scales and brackets above leaf
parameters are left out, as the v2 catalog leaves them out.
"""
return frozenset(
parameter.name
for parameter in self.tax_benefit_system.parameters.get_descendants()
if isinstance(parameter, Parameter)
)

def build_metadata(self) -> None:
"""Build the JSON-safe metadata document served by the metadata endpoints."""
self.metadata = self._json_safe(
Expand Down
10 changes: 9 additions & 1 deletion policyengine_api/data/v2/catalog/catalog_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class MetadataCatalogUnavailableError(RuntimeError):
"""Raised when an initialized catalog cannot be read."""


class MetadataCatalogNotPublishedError(MetadataCatalogUnavailableError):
"""Raised when the running PolicyEngine.py version has no catalog for a country.

The catalog was queried and holds no such version, as opposed to a query
that failed.
"""


class UnsupportedPreviewCountryError(ValueError):
"""Raised when a country has no Stage 9 resource catalog."""

Expand Down Expand Up @@ -107,7 +115,7 @@ def select_catalog(
f"the running PolicyEngine.py {selected_version} catalog "
f"is absent for {country_id}"
)
raise MetadataCatalogUnavailableError(msg)
raise MetadataCatalogNotPublishedError(msg)
model, model_version = row
return SelectedCatalog(
country_id=country_id,
Expand Down
8 changes: 4 additions & 4 deletions policyengine_api/fastapi_routes/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@
ParameterValueFilter,
)
from policyengine_api.services.v2.policies.types import (
CheckedPolicyPage,
CheckedPolicyRead,
NativePolicyCreation,
NativePolicyCreationInput,
PolicyPage,
PolicyRead,
)
from policyengine_api.services.v2.user_accounts.types import (
AccountDeletion,
Expand Down Expand Up @@ -326,7 +326,7 @@ def create_policy(
"""
...

def get_policy(self, *, country_id: str, policy_id: UUID) -> PolicyRead:
def get_policy(self, *, country_id: str, policy_id: UUID) -> CheckedPolicyRead:
"""Return one policy of the country with its parameter values.

Raise ``PolicyNotFoundError`` when no such policy exists in that country.
Expand All @@ -340,7 +340,7 @@ def list_policies(
tax_benefit_model_id: UUID | None = None,
offset: int = 0,
limit: int = 100,
) -> PolicyPage:
) -> CheckedPolicyPage:
"""Return a page of the country's policies, optionally for one model."""
...

Expand Down
26 changes: 21 additions & 5 deletions policyengine_api/fastapi_routes/v2/policies/response_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar
from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar
from uuid import UUID

from pydantic import BaseModel, ConfigDict, JsonValue
from pydantic import BaseModel, ConfigDict, Field, JsonValue

from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse

if TYPE_CHECKING:
from policyengine_api.services.v2.policies.types import PolicyPage, PolicyRead
from policyengine_api.services.v2.policies.types import (
CheckedPolicyPage,
CheckedPolicyRead,
)


class StrictPolicyAPIModel(BaseModel):
Expand Down Expand Up @@ -52,9 +55,22 @@ class PolicyItem(StrictPolicyAPIModel):
created_at: datetime
updated_at: datetime
parameter_values: list[PolicyParameterValueItem]
missing_parameters: Annotated[
list[str] | None,
Field(
description=(
"The parameter paths this policy sets that the current "
"tax-benefit model (the catalog of the running PolicyEngine.py "
"version) no longer has, sorted; empty when it has them all. "
"Null when that catalog is not published, so nothing could be "
"checked. A policy with missing parameters cannot be run on "
"the current model."
)
),
]

@classmethod
def from_read(cls, item: PolicyRead) -> PolicyItem:
def from_read(cls, item: CheckedPolicyRead) -> PolicyItem:
"""Build the response item by validating the service read record's fields."""
return cls.model_validate(item)

Expand All @@ -78,7 +94,7 @@ class PolicyPageResult(StrictPolicyAPIModel):
has_more: bool

@classmethod
def from_page(cls, page: PolicyPage) -> PolicyPageResult:
def from_page(cls, page: CheckedPolicyPage) -> PolicyPageResult:
"""Build the response page from a service page, converting each item."""
return cls(
items=[PolicyItem.from_read(item) for item in page.items],
Expand Down
11 changes: 11 additions & 0 deletions policyengine_api/openapi_spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ paths:
type: string
api_version:
type: string
missing_parameters:
type: array
nullable: true
items:
type: string
description: >-
The parameter paths this policy sets that the
country model the API runs no longer has, sorted;
empty when it has them all. Null when they cannot
be checked. A policy with missing parameters cannot
be run on the current model.
404:
description: Policy not found.
content:
Expand Down
19 changes: 14 additions & 5 deletions policyengine_api/routes/policy_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
PolicyMirrorUnavailableError,
mirror_policy_after_commit,
)
from policyengine_api.services.policy_parameter_service import PolicyParameterService
from policyengine_api.services.policy_service import PolicyService
from policyengine_api.services.user_policy_mirroring import (
UserPolicyMirrorCollaborators,
Expand All @@ -48,6 +49,7 @@

policy_bp = Blueprint("policy", __name__)
policy_service = PolicyService()
policy_parameter_service = PolicyParameterService()
user_policy_service = UserPolicyService()

# v1 policy and saved-policy IDs are MySQL signed INT values.
Expand Down Expand Up @@ -254,8 +256,11 @@ def get_policy(country_id: str, policy_id: int | str) -> Response:
policy_id (int | str): ID of the stored policy; cast to int.

Returns:
Response: The policy as JSON under ``result``. An unknown ID gives 404,
and an invalid policy read-source setting gives 503.
Response: The policy as JSON under ``result``, with
``missing_parameters``: the parameter paths it sets that the country
model the Public API runs no longer has (sorted, empty when it has
them all), or null when they cannot be checked. An unknown ID gives
404, and an invalid policy read-source setting gives 503.

"""
try:
Expand All @@ -267,11 +272,15 @@ def get_policy(country_id: str, policy_id: int | str) -> Response:
policy_id = int(policy_id)

policy = policy_service.get_policy(country_id, policy_id)
result = None if policy is None else _serialize_policy(policy)

if result is None:
if policy is None:
msg = f"Policy #{policy_id} not found."
raise NotFound(msg)
result = {
**_serialize_policy(policy),
"missing_parameters": policy_parameter_service.missing_parameters(
country_id, policy.policy_json
),
}

return Response(
json.dumps({"status": "ok", "message": None, "result": result}),
Expand Down
7 changes: 4 additions & 3 deletions policyengine_api/services/policy_mirroring.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sqlalchemy.exc import SQLAlchemyError

from policyengine_api.data.v2.catalog.catalog_selection import (
MetadataCatalogNotPublishedError,
MetadataCatalogUnavailableError,
MetadataCatalogVersionNotFoundError,
)
Expand Down Expand Up @@ -186,9 +187,9 @@ def classify_policy_copy_failure(error: Exception) -> PolicyCopyFailure:
)
if isinstance(error, MetadataCatalogUnavailableError):
reason = (
PolicyCopyFailureReason.CATALOG_QUERY_FAILED
if isinstance(error.__cause__, SQLAlchemyError)
else PolicyCopyFailureReason.CATALOG_ABSENT
PolicyCopyFailureReason.CATALOG_ABSENT
if isinstance(error, MetadataCatalogNotPublishedError)
else PolicyCopyFailureReason.CATALOG_QUERY_FAILED
)
return PolicyCopyFailure(
PolicyCopyFailureCategory.CATALOG_OR_TRANSLATION, reason
Expand Down
66 changes: 66 additions & 0 deletions policyengine_api/services/policy_parameter_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Check a v1 policy's parameter paths against the country model the API runs."""

from __future__ import annotations

from typing import TYPE_CHECKING

from policyengine_api.services.v2.policies.validators import (
parameters_missing_from_model,
)

if TYPE_CHECKING:
from collections.abc import Callable, Container


def loaded_model_parameter_names(country_id: str) -> Container[str] | None:
"""Return the leaf parameter paths of the country model the API runs.

None for a country without a loaded model. The models load at startup
(see policyengine_api.warmup); a process that has not loaded them loads
them here.
"""
from policyengine_api.country import ( # noqa: PLC0415 # importing country loads every country model; only a check that needs one does
COUNTRIES,
)

country = COUNTRIES.get(country_id)
return None if country is None else country.parameter_names


class PolicyParameterService:
"""Report which parameters a v1 policy sets that the current model lacks.

The current model is the country package the Public API runs, whose
catalog the v2 copy of the same release holds. The comparison is the one
the v2 copy uses, parameters_missing_from_model. Nothing is read from
Supabase, so v1 policy reads stay on Cloud SQL.
"""

def __init__(
self,
model_parameter_names: Callable[[str], Container[str] | None] | None = None,
) -> None:
"""Read model parameter paths through ``model_parameter_names``.

Without it, the loaded country models are read.
"""
self._model_parameter_names = (
model_parameter_names or loaded_model_parameter_names
)

def missing_parameters(
self, country_id: str, policy_json: object
) -> list[str] | None:
"""Return the parameter paths the policy sets that the model lacks, sorted.

Return None when nothing can be checked: the stored policy is not a
parameter-path object, or the country has no loaded model.
"""
if not isinstance(policy_json, dict):
return None
if not policy_json:
return []
model_parameter_names = self._model_parameter_names(country_id)
if model_parameter_names is None:
return None
return list(parameters_missing_from_model(policy_json, model_parameter_names))
31 changes: 21 additions & 10 deletions policyengine_api/services/v2/policies/database_connectors/reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
if TYPE_CHECKING:
from uuid import UUID

# Parameter names bound in one statement; far below PostgreSQL's 65,535 limit.
PARAMETER_NAME_BATCH_SIZE = 1_000


def read_policy_catalog(
session: Session,
Expand Down Expand Up @@ -61,16 +64,24 @@ def read_version_parameter_ids(
def read_parameters_by_name(
session: Session, *, model_version_id: UUID, names: set[str]
) -> dict[str, Parameter]:
"""Return this model version's parameters with the given names, keyed by name."""
if not names:
return {}
parameters = session.exec(
select(Parameter).where(
Parameter.tax_benefit_model_version_id == model_version_id,
col(Parameter.name).in_(names),
)
).all()
return {parameter.name: parameter for parameter in parameters}
"""Return this model version's parameters with the given names, keyed by name.

The names are read in batches of PARAMETER_NAME_BATCH_SIZE, because a
PostgreSQL statement takes at most 65,535 bound values and a page of
policies can name more parameters than that.
"""
ordered_names = sorted(names)
found: dict[str, Parameter] = {}
for start in range(0, len(ordered_names), PARAMETER_NAME_BATCH_SIZE):
batch = ordered_names[start : start + PARAMETER_NAME_BATCH_SIZE]
parameters = session.exec(
select(Parameter).where(
Parameter.tax_benefit_model_version_id == model_version_id,
col(Parameter.name).in_(batch),
)
).all()
found.update((parameter.name, parameter) for parameter in parameters)
return found


def read_policy_by_content_identity(
Expand Down
Loading
Loading