Plan: Typed Path<*Id> rollout — workspace-wide (#627)
On this page
Status
| Step | Description | Status |
|---|---|---|
0 |
Commit this plan as |
Done (2026-07-09) — !786 |
1 |
Shared: FTI-audit id ( |
Done (2026-07-09) — !787 |
2 |
Shared: overpayments crate, full thread-through + all consumers |
Done (2026-07-09) — !788 |
3 |
Service: canopy-wic (incl. appointment route re-scope to household) |
Done (2026-07-09) — !789 |
4 |
Service: canopy-snap |
Done (2026-07-09) — !790 |
5 |
Service: canopy-caps (incl. provider PK v4→v7) |
Done (2026-07-09) — !791 |
6 |
Service: canopy-medicaid |
Done (2026-07-09) — !792 |
7 |
Service: canopy-tanf |
Done (2026-07-09) — !793 |
8 |
Service: canopy-applications |
Done (2026-07-09) — !795 |
9 |
Service: canopy-persons (remaining tuple |
Done (2026-07-10) — !799 |
10 |
Service: canopy-security |
Done (2026-07-10) — !801 |
11 |
Finalization: workspace |
Done (2026-07-10) — !802 |
Issue: #627
Branches: one per step — feature/627-typed-ids-{slug}
Reference commit: 5bb503f7 — the verification slice (MR !785), already merged, is the proven template.
Context
The define_id! typed-ID migration (crates/canopy-common/src/id.rs) replaced raw Uuid path
parameters with per-entity newtypes across most services in April 2026 but left holdouts. The
verification slice was since completed (5bb503f7) as the template. A workspace audit finds 61 raw
Path<Uuid> extractors remain = the 46 in the five services the issue names (tanf 17, medicaid 12,
caps 8, snap 5, wic 4) plus canopy-applications (10), canopy-persons (4), canopy-security (1). Per
the owner decision this plan migrates all 61 so the finalization lint can ban the pattern with a
near-empty allowlist — the sole entries are the 2 canopy-applications worker Path sites, which a
worker is an issuer-scoped OIDC subject (a string), not a domain UUID: typing that awaits the
worker-identity redesign (Step 8 decision; #559/#493 + multi-IdP). (Per-step site counts, which regroup
the shared FTI 2 + overpayments 12 into Steps 1–2: 2+12+4+1+8+7+12+10+4+1 = 61.)
Typed path IDs turn an ID-transposition bug into a compile error. This is a behaviour-preserving refactor (the newtypes are wire/OpenAPI/DB-transparent) with two deliberate deviations the owner approved: the shared overpayments DTO crate is fully typed (below), and the incoherent wic appointment route is re-scoped (a pre-1.0 wire change, below).
Scope
In scope: every raw Path<Uuid> / Path<uuid::Uuid> / tuple Path<(… Uuid …)> under
services/*/src/api/; the newtypes each needs; threading each id through its store fn(s) + the entity’s
contract .id; the shared canopy-overpayments crate + its cross-service consumers; the wic appointment
route re-scope; a workspace lint banning Path<Uuid>.
Out of scope: any behaviour change other than the two approved deviations (overpayments full-thread,
wic re-scope); #628 (DeterminationStatus).
Design
The newtype is transparent (no wire change)
define_id! derives [sqlx(transparent)] + [serde(transparent)]
[schema(value_type = String, format = "uuid")] + Display/FromStr/From<Uuid>/Into<Uuid>. A
Uuid→XxxId swap is byte-identical on the wire and in Postgres, and in OpenAPI for a path parameter
(kept documented as uuid::Uuid, see The utoipa params() doc-type stays uuid::Uuid (extractor only is typed)). Typing a DTO field of a struct registered in
an [openapi(components(schemas(…)))] list is not byte-identical in OpenAPI: utoipa emits the newtype
as a named component and the field renders as a $ref to it (each component is {type: string, format:
uuid}) — wire-compatible and matching the merged canopy-persons/canopy-applications house style, but a
legitimate snapshot regen (accept via --update). So cargo xtask api-docs shows zero drift for a
slice touching only path extractors + non-registered fields, and a $ref-representation change for a
slice that types fields of a registered DTO (e.g. Step 2’s overpayments crate). The failures to fix
(never --update) are a dropped #[schema] attr or a dangling $ref (a path-param newtype declared
= XxxId but absent from the schemas list, The utoipa params() doc-type stays uuid::Uuid (extractor only is typed)). (One non-define_id! newtype — WorkerId
— is hand-rolled but equally transparent.)
The utoipa params() doc-type stays uuid::Uuid (extractor only is typed)
Type the axum extractor (Path<XxxId>) but leave the #[utoipa::path(params"id" = uuid::Uuid, …)]
doc-type as uuid::Uuid — the proven pattern in the merged canopy-persons + the verification
template. Declaring params"id" = XxxId, … makes utoipa emit $ref: #/components/schemas/XxxId,
but a define_id! newtype used only as a path param is not registered in components(schemas(…)), so
the $ref dangles (invalid OpenAPI) — and it drifts the snapshot besides. Keep the doc-type at the wire
type; the extractor carries the type-safety, and api-docs stays zero-drift. (Fully-qualify as
= uuid::Uuid to avoid an otherwise-unused use uuid::Uuid — the params() annotation does not count
as a use in the handler module.) Step 1’s FTI handler mistakenly used = FtiAuditEntryId, producing a
dangling $ref; Step 2 corrected both FTI handlers back to = uuid::Uuid (its api-docs battery had
passed against stale devstack binaries).
Threading rule (three tiers)
-
Always:
Path<Uuid>→Path<XxxId>; store fn paramid: Uuid→XxxId. -
Contract
.idfield →XxxIdwhen the entity is service-owned and threads without an.into_inner()fig-leaf; keep any existing#[schema(value_type = String)]; row-mirror keeps itsUuidcolumn +id: row.id.into(). -
Stays
Uuid(correct boundary, not a fig-leaf): an id fed into aUuid-native event/RabbitMQ pipeline, a DTO another service deserialises asUuid, or a shared generic helper (e.g. persons'require_fact_ownership(table: &str, …), canopy-dbshred_with) — pass.into_inner()at that call.
Approved exceptions
-
Overpayments crate — FULL thread-through (owner decision). Unlike Tier-3,
canopy-overpaymentsDTO ids AND FK fields are typed, and every consumer is updated in the same MR (Step 2). Rationale: owner wants maximal safety on this shared surface. -
wic appointment route — re-scope (owner decision, pre-1.0). Step 3; eliminates the
certification_idfiction rather than typing it. -
Determination response DTOs (
{Caps,Wic,Medicaid,Tanf}Determination.id) stayUuid(Tier-3): consumed bycanopy-web/determination_view.rs, minted indetermine.rs, copied into signing/events. Path + store param ARE typedDeterminationId. -
medicaid ELE store internals stay
Uuid(Tier-3): shared with RabbitMQ consumers (main.rs,scheduler.rs). The 3 ELE Path extractors are typed; convert with.into_inner().
New newtypes (15 new; 9 existing reused)
New via define_id! in crates/canopy-common/src/id.rs (15): FtiAuditEntryId, RepaymentPlanId,
RecoupmentLedgerEntryId, WicParticipantId, WicAssessmentId, TsnapCertificationId,
CapsAuthorizationId, CapsProviderId, MedicaidApplicationId, TanfApplicationId,
TanfPersonalResponsibilityId, TanfDiscrepancyId, HouseholdAssignmentId, RecoveryId, FactId
(generic ADR-025 fact handle, for the polymorphic redact route).
No worker newtype (Step 8 decision). An earlier draft added a hand-rolled WorkerId; that was
dropped. A worker is identified by its issuer-scoped OIDC subject (a string — the repo already
has KeycloakSub), not a domain UUID. household_assignments.worker_id is only a UUID because a
single-realm BFF projects sub→UUID (which breaks under multi-IdP). Wrapping that in a UUID newtype
would bless a broken model (as composition::UserId already mistakenly does) and violate id.rs’s
"all IDs are UUID v7" invariant. So worker path/columns stay raw `Uuid; the assign/gate
transposition is caught by typing the household side (HouseholdId), and the 2 worker Path
sites are Step-11-allowlisted pending the worker-identity redesign (see Step 8 / Step 11).
Reused existing (9): DeterminationId, PersonId, HouseholdId, OverpaymentClaimId, ApplicationId,
DocumentId, HouseholdMemberId, AddressId, IncomeId.
Contract crates needing a new canopy-common dep (verified absent): contracts-caps, contracts-wic,
contracts-tanf, canopy-overpayments. (snap/medicaid/applications/persons already have it.)
Steps
Each step = one MR via Per-slice recipe (every step), independently mergeable; shared surfaces (1–2) first. Every new .rs
file starts with // SPDX-License-Identifier: AGPL-3.0-or-later.
Step 0 — Commit the plan. Write this plan (body only) to docs/modules/ROOT/pages/plans/typed-path-id-rollout.adoc,
add its nav.adoc xref under "Code Quality & Infrastructure", commit on feature/627-typed-ids-plan →
docs-MR → merge. (Plans are in-repo .adoc + nav-linked before implementation.)
Step 1 — Shared FTI id (2 sites). Mint FtiAuditEntryId; type FtiAuditEntry.id,
FtiAuditLogger::get_entry, and the PostgresFtiAuditLogger impl (all crates/canopy-common/src/fti_audit.rs);
type Path<FtiAuditEntryId> at services/canopy-{tanf,medicaid}/src/api/fti_audit_handlers.rs:92; fix the 3
construction sites (medicaid/tanf determine.rs, tanf fti_audit_hash_chain_test.rs).
Step 2 — Shared overpayments, full thread-through (12 path sites + consumers). Mint RepaymentPlanId,
RecoupmentLedgerEntryId (reuse OverpaymentClaimId); add canopy-common to canopy-overpayments. Type
ALL id + FK fields in canopy-overpayments/src/lib.rs (OverpaymentClaim/RepaymentPlan/
RecoupmentLedgerEntry/CreateClaimRequest: id, overpayment_claim_id, repayment_plan_id, person_id,
household_id, determination_id). Type the 4 path sites + store fns in each of snap/tanf/medicaid
(api/overpayments_handler.rs:{109,137,165,191} + store/overpayments.rs). Update consumers (same MR):
canopy-reporting clients/mod.rs — list_overpayment_claims (:342, returns Vec<OverpaymentClaim>) and
get_overpayment_ledger (:363, param claim_id: Uuid, returns LedgerView); reporting/overpayments.rs
loops those and reads claim.id/claim.person_id/… (:38-48) + passes claim.id into get_overpayment_ledger
(:39, needs .into_inner()); the snap/tanf/medicaid main.rs RabbitMQ CreateClaimRequest construction
sites + snap recompute_persist.rs/ipv_claim.rs. AlreadyClosed { id: claim_id.into() }.
Step 3 — canopy-wic (4 sites; + appointment route re-scope). Add canopy-common to contracts-wic. Mint
WicParticipantId, WicAssessmentId.
| Handler | Path type | Note |
|---|---|---|
|
|
Path+store; DTO id stays |
|
|
full thread + roundtrip test |
|
|
symmetric req/resp DTO; |
|
|
see below |
Appointment re-scope (owner-approved, pre-1.0): the value is a fiction — no wic_certifications table,
the server aliases it to household_id (let household_id = certification_id), and the BFF
(canopy-web/src/api/actions_wic.rs::schedule_certification_appointment_wic) already holds form.household_id.
Change the route to POST /v1/wic/households/{household_id}/appointments, Path<HouseholdId>; the BFF passes
form.household_id; stop writing the bogus wic_appointments.certification_id column (leave NULL). Drop
certification_id from the ScheduleCertificationAppointmentWicForm (BFF form) and from the response DTO
WicAppointment (crates/canopy-contracts-wic/src/appointments.rs:43). Note: ScheduleAppointmentRequest
has NO certification_id field (nothing to drop there; the BFF request body already omits it). Relate to #571 (this supersedes its participant-indirection;
note #571 for close/reduce). Verify the upcoming_appointments panel doesn’t read certification_id.
OpenAPI exception: this route-path + body change is a deliberate pre-1.0 wire change, so
cargo xtask api-docs --update IS expected on this slice (regenerate + CHANGELOG the route change) — the
"zero drift" rule in Per-slice recipe (every step) holds for every OTHER slice.
Step 4 — canopy-snap (1 site). contracts-snap has canopy-common. Mint TsnapCertificationId; type
get_tsnap (tsnap_handler.rs:28) + TsnapCertification.id + store/tsnap.rs:94. (overpayments → Step 2.)
Step 5 — canopy-caps (8 sites; + PK fix). Add canopy-common to contracts-caps. Mint CapsAuthorizationId,
CapsProviderId.
| Handlers | Path type |
|---|---|
|
|
|
|
|
|
Also type SwitchProviderRequest.new_provider_id + AuthorizationCreatedEvent.authorization_id. Fix in-slice:
providers.rs:52 Uuid::new_v4() → CapsProviderId::new() (v7) — the PK must be v7 (owner: fix, don’t defer).
Step 6 — canopy-medicaid (7 sites). Mint MedicaidApplicationId. (overpayments → Step 2; FTI → Step 1.)
| Handler | Path type | Note |
|---|---|---|
|
|
DTO id stays |
|
|
inline SQL binds |
|
|
boundary |
|
|
boundary |
Step 7 — canopy-tanf (12 sites). Add canopy-common to contracts-tanf. Mint TanfPersonalResponsibilityId,
TanfApplicationId, TanfDiscrepancyId. (overpayments → Step 2; FTI → Step 1.)
| Handler(s) | Path type | Note |
|---|---|---|
|
|
different files; one shared store fn |
|
|
tanf-owned table, NOT snap’s IEVS |
|
|
full thread |
PR create ( |
|
FK is |
|
|
6 sites; |
Step 8 — canopy-applications (+ the enrollment gate). Mint HouseholdAssignmentId, RecoveryId.
Worker identity stays raw Uuid (see the newtype note): the 2 worker Path sites are left Path<Uuid>
and Step-11-allowlisted; transposition safety comes from typing the household side.
| Handler(s) | Path type |
|---|---|
assignments |
|
assignments |
|
assignments |
|
documents |
|
documents |
|
recovery |
|
Contract typing: HouseholdAssignment.{id→HouseholdAssignmentId, household_id→HouseholdId} — keep their
#[schema(value_type=String)] overrides → snapshot-neutral; worker_id stays Uuid (Tier-3: the enrollment
gate deserialises it cross-service as Uuid). ApplicationDocument.{id→DocumentId, application_id→ApplicationId,
person_id→PersonId} have no overrides → the applications OpenAPI snapshot regenerates (a new DocumentId
component + $ref`s; wire-identical → `api-docs --update); accepted_by stays Uuid. Recovery DTOs/events stay
Uuid (a Uuid-native event pipeline read back by canopy-notices via GET /recover/{recovery_id}). Row mirrors
store fns threaded to match; recovery is minimal (recover_get Path + store::get_by_id). Enrollment gate
(Decision — included): is_worker_assigned_to_household (canopy-enrollment/src/clients/mod.rs:65) gets
household_id → HouseholdId (worker stays Uuid) so the Pub-1075 §9.3.1 (worker, household) authz transposition
is a compile error — the caller already holds HouseholdId. Test hardening: added the missing
store/assignments.rs unit-test module (covers is_assigned) and an infrastructure_available() guard to
document_test/recovery_test (they skipped silently under CANOPY_CI). Correct worker typing is deferred to the
worker-identity redesign (follow-up #1008; relates #559/#493 + multi-IdP). The document accepted_by trust-boundary
hardening (derive from verified claims, not the body) is follow-up #1009.
Step 9 — canopy-persons (4 tuple sites). persons already has canopy-common. Mint FactId. Each tuple’s
first element (household_id/person_id) is already typed; only the fact_id (last) element is retyped.
| Handler | fact_id type | Note |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
runtime-polymorphic |
The 3 close_*_version store fns type the public param and convert once at entry (let fact_id =
fact_id.into_inner();) — the shared bitemporal engine (lock_fact/snapshot_and_supersede/
reinsert_remnants, also driven by the claim path) stays Uuid. persons has no worker Path sites, so its
residual-Path<Uuid> grep is empty (nothing to allowlist here).
Step 10 — canopy-security (1 site). fact_change_history (mod.rs:193) Path<(uuid::Uuid, String)> →
(PersonId, String); store list_fact_change_history takes Uuid → .into_inner() (the audit_events
store is a generic cross-service ledger, Tier-3); resource stays String. The FactChangeEntry response
DTO keeps its Uuid fields (person_id/fact_id/version_id — a generic cross-service audit record read
from persons' events, Tier-3), so — with the utoipa path-param doc-type left = Uuid — the security OpenAPI
snapshot is unchanged.
Step 11 — Finalization lint (final MR carries the closing keyword for #627). New xtask/src/cmd/typed_ids.rs::run_audit_path_uuid() (SPDX
header; registered in cmd/mod.rs AND wired as a Command variant in xtask/src/main.rs with dispatch, so
cargo xtask typed-ids audit-path-uuid runs standalone). Regex catches Path<Uuid>, Path<uuid::Uuid>, and
tuple Path<(… Uuid …)> across services/ + crates/; bails with a file:line list. Allowlist file
compliance/typed-id-path-allowlist.toml — the 2 worker Path sites only
(canopy-applications/src/api/assignments.rs create_assignment + list_assignments_by_worker), each with a
reason pointing at the worker-identity redesign (issuer-scoped OIDC subject, not a domain UUID — #1008,
which relates #559/#493 + the multi-IdP epic). Every other raw Path<Uuid> in the tree is typed; the
allowlist entries are removed by that follow-up, not by Step 11. Wire as a numbered step in
cargo xtask validate after the ADR-011 audits + mirror as a CI job. The MR for this step carries the
closing keyword for #627 (see recipe — do not write that literal phrase in any earlier MR).
Per-slice recipe (every step)
-
Branch
feature/627-typed-ids-{slug}offmain. -
Mint the step’s newtype(s); add the
canopy-commondep to the contract crate if listed. -
Apply the Threading rule (three tiers) map (+ the Approved exceptions where noted); new
.rsfiles get the SPDX header. -
Gates:
cargo check --workspace --all-targets→cargo clippy -p <touched> --all-targets --profile test — -D warnings→cargo xtask api-docs(needs adev refreshfirst so the running services reflect the slice) — zero drift for a path-extractor-only slice; a$ref-to-registered-component change from typing a registered DTO field is legitimate →--update; a dropped#[schema]attr or a dangling$refis the failure to fix, never--update(see the transparency + The utoipaparams()doc-type staysuuid::Uuid(extractor only is typed) notes) →cargo xtask quality-budgets --fail-on-regression→cargo fmt --all. -
CHANGELOG.adoc=== Changedentry; one fresh J1–J8 pre-commit subagent review (the reflection gate in.claude/rules/pre-commit-token-protocol.md). -
Commit
refactor(<svc>): … (#627)+ aCo-Authored-By:trailer naming the actual model of the implementing session (never a hardcoded value); pre-push battery green; open MR (body = Summary / Changes / Test Plan; link the issue withRelates to #627for Steps 0–10). -
After merge: update this plan’s Status cell for the step to
Done (YYYY-MM-DD) — !MR; post a one-line progress comment on #627. -
Step 11 only: the MR links the issue with the closing keyword for #627; after merge, leave the full closing comment (implementation SHA + bare merge SHA + changed files + checked criteria + follow-ups filed), close the issue, and move this plan’s
nav.adocentry to Archive.
Files Touched (representative)
| File(s) | Change |
|---|---|
|
+15 |
|
Step 8: type entity ids (documents DTO → OpenAPI regen; worker_id stays |
|
Step 8: household-access gate |
|
type |
|
+dep; full id/FK typing (Step 2) |
|
overpayments consumer updates (Step 2) |
|
Path + store fn + row-mirror per step |
|
+dep; type entity |
|
appointment re-scope (Step 3) |
|
Step 11 lint + CI mirror ( |
|
Step 11 |
|
Step 0 |
|
|
Verification
-
cargo check --workspace --all-targetsclean. -
cargo clippytouched--profile test — -D warningsclean. -
cargo xtask api-docssnapshots match (transparency). -
cargo xtask quality-budgets --fail-on-regressionpasses. -
Full pre-push battery green per slice.
-
Step 11: the lint fails on an injected
Path<Uuid>/Path<uuid::Uuid>/tuple form and passes on the tree. -
Step 3: exercise the re-scoped appointment route end-to-end (wic appointment integration test + the #392 BFF action).
Documentation Updates
-
CHANGELOG.adoc=== Changedper slice; Step 3 also notes the wic appointment route change (pre-1.0 wire). -
Step 11: document the
Path<Uuid>lint by the other xtask audits (inline doc-comment + CI-job comment + allowlist header + this plan; the validate step-list itself lives in the synced standard, per coding-conventions.adoc). -
Follow-ups (
fix:issues,/relate#627): medicaid/determinations/{id}/categoriesURL mismatch;overpayments_handler.rsper-service duplication (DRY); #571 review (superseded by Step 3’s re-scope);household_assignments.id+recovery_pending.idPK defaults aregen_random_uuid()(v4) — migrate to v7 per the UUID-v7 rule (pre-existing; the typed-ID change keeps theUuidcolumn, so this is separate).