Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8c8d75
Derive each catalog version's parameter tree as rows of direct children
anth-volk Oct 22, 2026
8732183
Store each catalog version's parameter tree in its own table
anth-volk Oct 26, 2026
b35c854
Write each stored catalog version's parameter tree during publication
anth-volk Oct 26, 2026
8bd6774
Serve /v2/parameters/children from the stored parameter tree
anth-volk Oct 26, 2026
472a628
Return parameter tree labels in the form v3 shows them
anth-volk Oct 26, 2026
a38f6c0
Write the parameter tree only for the catalog versions a publication …
anth-volk Oct 26, 2026
c5bb630
Publish the parameter tree as the catalog writer role in a test
anth-volk Oct 26, 2026
20d9a41
Document the children route's statuses and page the reform view in a …
anth-volk Oct 26, 2026
cc44898
Test the parameter tree with records shaped like PolicyEngine.py's ou…
anth-volk Oct 26, 2026
97904be
Type a parameter tree row's child type with its two values
anth-volk Oct 26, 2026
9983a3a
Drop the reform rule for parameters without flags
anth-volk Oct 26, 2026
bfe79da
Declare exactly the errors the parameter tree route returns
anth-volk Oct 26, 2026
6c3bbcf
Give the installed catalog version its parameter tree when it has none
anth-volk Oct 26, 2026
89156a2
Test that parameter tree responses are cached like other metadata
anth-volk Oct 26, 2026
4d14c0d
Write no parameter tree for a version stored before catalog format 2
anth-volk Oct 26, 2026
03d0818
Test the parameter tree's link to stored parameters by name
anth-volk Oct 26, 2026
b7300b3
State the parameter tree's real rule in its documents and messages
anth-volk Oct 26, 2026
d6f4af3
Name the catalog writer in the runtime-role rule and the Alembic skill
anth-volk Oct 26, 2026
37ab948
Look up a stored catalog version's ID and format in one place
anth-volk Oct 26, 2026
2e41451
Add the catalog format 2 condition to the tree writer's descriptions
anth-volk Oct 26, 2026
edb0a8e
Test the tree's parameter IDs with stored IDs unlike the catalog's
anth-volk Oct 26, 2026
1cc6f62
Check that a version stored before catalog format 2 returns the tree 503
anth-volk Oct 26, 2026
4519b8e
Say which metadata read the runtime-role rule's tests cover
anth-volk Oct 26, 2026
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
1 change: 1 addition & 0 deletions changelog.d/v2plan-1f-117-parameter-children.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Catalog publication writes parameter trees into a new table, parameter_tree_children, built from the installed packages' catalog records for each version: one row per node and parameter under its parent's path, with its label, its position among its siblings, its direct child counts, and whether a reform can change it. A bracket schedule is one node whose children are its brackets, labelled "Bracket 1", "Bracket 2" and so on, and labels take the form the app's v3 workspace shows, with a path without a stored label getting one made from the last part of its name. Publication writes the tree of each version it publishes, and of the installed version when an earlier release stored it with catalog format 2 but without one; its evidence lists them. Other stored versions, and an installed version stored before catalog format 2, have no tree.
1 change: 1 addition & 0 deletions changelog.d/v2plan-1f-117-parameter-children.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/v2/parameters/children reads one level of the stored parameter tree with one indexed read. A bracket schedule is now listed as one node whose children are its brackets, instead of its brackets appearing beside its parent's groups under their raw names, and every child carries its label in the form the app's v3 workspace shows: the stored label with underscores as spaces and its first letter upper-cased, or one made from the last part of its name. reform_changeable=true lists only the children a reform can change, sorted by label, with child counts that count only those, following the rules of the tree the app's v3 workspace builds. The route now reads its query through the shared strict models, so unknown or repeated parameters, an unsupported country and a malformed policyengine_version return 422 (an unsupported country and a malformed version returned 400 before), and a catalog version without a tree returns 503 with a message naming the version.
9 changes: 6 additions & 3 deletions docs/engineering/skills/alembic-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,10 @@ check the declarations:
- `api.v2.runtime-privileges-declared`: a unit test reads each connector
module and fails when it needs a privilege its role isn't declared to hold.
- `api.v2.runtime-privileges-tested-as-runtime-role`: the cross-database
integration tests run the v2 user services as `policyengine_v2_runtime` and
the comparison-run services as `policyengine_stage12_test`, holding only
their declared privileges.
integration tests run the v2 user services and the parameter tree read as
`policyengine_v2_runtime`, catalog publication as
`policyengine_v2_catalog_writer`, and the comparison-run services as
`policyengine_stage12_test`, holding only their declared privileges.
- `api.v2.deployed-privileges-match`: every deploy runs
`scripts/reconcile_v2_privileges.py apply` right after the schema upgrade,
then `verify`, before the catalog is seeded.
Expand Down Expand Up @@ -183,6 +184,8 @@ When a pull request adds or changes a v2 table, or a connector's use of one:
uv run pytest -q tests/integration/test_v2_user_identity_persistence.py \
tests/integration/test_v2_saved_policy_view.py \
tests/integration/test_v2_stage12_comparison_persistence.py \
tests/integration/test_v2_metadata_routes.py \
tests/integration/test_v2_catalog_publication.py \
tests/integration/test_v2_privilege_reconciliation.py
```

Expand Down
2 changes: 1 addition & 1 deletion docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ flowchart LR
| `api.migration.cross-database-integration` | Cross-database integration tests pass against real MySQL and Postgres. | mechanical | enforced | yes | CI job `v2-integration-check` | `pr.yml:v2-integration-check` | — |
| `api.v2.deployed-privileges-match` | Every v2 deploy grants the managed roles each privilege policyengine_api/data/v2/privileges.py declares right after the schema upgrade, reports privileges it doesn't declare without revoking them, and stops before the catalog is seeded if a declared privilege is still missing. | mechanical | enforced | yes | CI job `seed`, `catalog-runtime-integration` | `seed-v2-database.yml:seed`, `v2-integration-check.yml:catalog-runtime-integration` | yes |
| `api.v2.runtime-privileges-declared` | Every v2 table has an entry for each managed database role in policyengine_api/data/v2/privileges.py, and every privilege a v2 connector module needs (from its reads, creates, updates or deletes module name and its row locks) is declared for the role that runs it. | mechanical | enforced | yes | pytest `tests/unit/v2/test_privilege_manifest.py` | `pr.yml:test` | yes |
| `api.v2.runtime-privileges-tested-as-runtime-role` | The cross-database integration tests run the v2 user services as policyengine_v2_runtime and the comparison-run services as policyengine_stage12_<environment>, holding only their declared privileges, so a statement those privileges don't allow fails CI. | mechanical | enforced | yes | CI job `v2-integration-check`, `catalog-runtime-integration` | `pr.yml:v2-integration-check`, `v2-integration-check.yml:catalog-runtime-integration` | — |
| `api.v2.runtime-privileges-tested-as-runtime-role` | The cross-database integration tests run the v2 user services and the parameter tree read as policyengine_v2_runtime, catalog publication as policyengine_v2_catalog_writer, and the comparison-run services as policyengine_stage12_<environment>, holding only their declared privileges, so a statement those privileges don't allow fails CI. | mechanical | enforced | yes | CI job `v2-integration-check`, `catalog-runtime-integration` | `pr.yml:v2-integration-check`, `v2-integration-check.yml:catalog-runtime-integration` | — |

```mermaid
flowchart LR
Expand Down
51 changes: 51 additions & 0 deletions docs/migration/stage-9-v2-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,55 @@ since they describe staged regions that were not published. Every other table
must still match exactly. Region reference changes therefore take effect in the
next catalog version that is published.

### Parameter tree

Publication also writes parameter trees into `parameter_tree_children`, which
`/v2/parameters/children` reads one level at a time. A tree is built from a
catalog version's parameter and node records: every node and parameter is one
row under its parent's path, a `[` starts a child as a `.` does (so a bracket schedule is one node
whose children are its brackets, labelled "Bracket 1", "Bracket 2" and so on),
and labels take the form the app's newer interface shows in every view: the
stored label with underscores as spaces and its first letter upper-cased, or,
without one, the last part of the name in that form (so an unlabelled schedule
`...rates.uk` is "Uk"). Each row also records its position among all its
siblings and, if a reform can change it, among the siblings a reform can
change: a parameter under `gov` whose economy or household flag is set and whose path names
neither `taxsim`, `gov.abolitions` nor `pycache`, and every node above such a
parameter. These are the rules of the tree the app's newer interface builds
from v1 metadata. Trees are written only for versions with catalog format 2,
so every parameter in a tree has both flags.

In the same transaction, the command writes the tree of each version in the
catalog it publishes, which comes from the installed packages, when that
version has none: the versions it publishes now, and the installed version when
an earlier release stored it with catalog format 2 but without a tree. An
installed version stored before catalog format 2 gets none, since its parameter
rows store no flags. The rows are built from the
catalog's own parameter and node records; only the stored parameters' IDs are
read, by name, so the rows reference them, after publication has checked that
the stored rows match the catalog. This is a row-only, idempotent insert of
package-derived rows, which the deployment-seeder passage of the Alembic skill
allows. The evidence's `parameter_trees_written` lists each version written,
with its model, version and row count, and publication fails if a version in
the catalog with catalog format 2 is still without a tree when it finishes.

Other stored versions get no tree. Writing one for them would derive rows from
what earlier releases stored, a data migration that the Alembic skill's rules
leave to a human migration decision, so it is not done; such a version gets a
tree only if a person decides on that migration.

`/v2/parameters/children` lists one level with one indexed read:
`parent_path` names the node ("" for the top level), and
`reform_changeable=true` keeps only the children a reform can change, sorted
by label, with child counts that count only those. Without it, children are
in path order, with a schedule's brackets in bracket order. A node carries
its child count, a parameter its summary. An unknown parent path has no
children. The route reads its query through the shared strict query models,
so unknown or repeated parameters, an unsupported country and a malformed
`policyengine_version` return 422 (the last two returned 400 before). A
catalog version without a tree returns 503 with a message that names the
version.

## Validation evidence and retry

Successful JSON evidence contains:
Expand All @@ -315,6 +364,8 @@ Successful JSON evidence contains:
- fallback counts and omitted-region counts by country and region type, and
the stored and staged region counts of any stored version whose regions were
kept;
- the model, version and row count of each catalog version whose parameter
tree was written;
- elapsed publication time; and
- a success outcome.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""store each catalog version's parameter tree

Revision ID: 3869eb814a90
Revises: f5c1d9b4685b
Create Date: 2026-10-22 22:51:53.959795
Generation: uv run alembic -c alembic-v2.ini revision --autogenerate
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


revision: str = "3869eb814a90"
down_revision: Union[str, None] = "f5c1d9b4685b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"parameter_tree_children",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("tax_benefit_model_version_id", sa.Uuid(), nullable=False),
sa.Column(
"parent_path", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=False
),
sa.Column("path", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column(
"label", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=False
),
sa.Column(
"child_type", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False
),
sa.Column("child_count", sa.Integer(), nullable=True),
sa.Column("reform_position", sa.Integer(), nullable=True),
sa.Column("reform_child_count", sa.Integer(), nullable=True),
sa.Column("parameter_id", sa.Uuid(), nullable=True),
sa.Column("economy", sa.Boolean(), nullable=True),
sa.Column("household", sa.Boolean(), nullable=True),
sa.CheckConstraint(
"(child_type = 'parameter') = (parameter_id IS NOT NULL)",
name=op.f("ck_parameter_tree_children_parameter_reference"),
),
sa.CheckConstraint(
"child_type IN ('node', 'parameter')",
name=op.f("ck_parameter_tree_children_child_type"),
),
sa.ForeignKeyConstraint(
["parameter_id"],
["parameters.id"],
name=op.f("fk_parameter_tree_children_parameter_id_parameters"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tax_benefit_model_version_id"],
["tax_benefit_model_versions.id"],
name=op.f(
"fk_parameter_tree_children_tax_benefit_model_version_id_tax_benefit_model_versions"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_parameter_tree_children")),
sa.UniqueConstraint(
"tax_benefit_model_version_id",
"path",
name="uq_parameter_tree_children_model_version_path",
),
)
op.create_index(
"ix_parameter_tree_children_listing",
"parameter_tree_children",
["tax_benefit_model_version_id", "parent_path", "position"],
unique=True,
)
op.create_index(
op.f("ix_parameter_tree_children_parameter_id"),
"parameter_tree_children",
["parameter_id"],
unique=False,
)
op.create_index(
"ix_parameter_tree_children_reform_listing",
"parameter_tree_children",
["tax_benefit_model_version_id", "parent_path", "reform_position"],
unique=True,
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"ix_parameter_tree_children_reform_listing",
table_name="parameter_tree_children",
)
op.drop_index(
op.f("ix_parameter_tree_children_parameter_id"),
table_name="parameter_tree_children",
)
op.drop_index(
"ix_parameter_tree_children_listing", table_name="parameter_tree_children"
)
op.drop_table("parameter_tree_children")
# ### end Alembic commands ###
Loading
Loading