Plan: Typed Path<*Id> rollout — workspace-wide (#627)

On this page

Status

Step Description Status

0

Commit this plan as .adoc + nav-link it (before any implementation)

Done (2026-07-09) — !786

1

Shared: FTI-audit id (FtiAuditEntryId; canopy-common + tanf/medicaid)

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 fact_id sites)

Done (2026-07-10) — !799

10

Service: canopy-security

Done (2026-07-10) — !801

11

Finalization: workspace Path<Uuid> ban lint; final MR carries the closing keyword for #627

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 UuidXxxId 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)

  1. Always: Path<Uuid>Path<XxxId>; store fn param id: UuidXxxId.

  2. Contract .id field → XxxId when the entity is service-owned and threads without an .into_inner() fig-leaf; keep any existing #[schema(value_type = String)]; row-mirror keeps its Uuid column + id: row.id.into().

  3. Stays Uuid (correct boundary, not a fig-leaf): an id fed into a Uuid-native event/RabbitMQ pipeline, a DTO another service deserialises as Uuid, or a shared generic helper (e.g. persons' require_fact_ownership(table: &str, …), canopy-db shred_with) — pass .into_inner() at that call.

Approved exceptions

  • Overpayments crate — FULL thread-through (owner decision). Unlike Tier-3, canopy-overpayments DTO 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_id fiction rather than typing it.

  • Determination response DTOs ({Caps,Wic,Medicaid,Tanf}Determination.id) stay Uuid (Tier-3): consumed by canopy-web/determination_view.rs, minted in determine.rs, copied into signing/events. Path + store param ARE typed DeterminationId.

  • 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.rslist_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

get_determination (handlers.rs:173)

DeterminationId

Path+store; DTO id stays Uuid

get_participant (handlers.rs:276)

WicParticipantId

full thread + roundtrip test

get_assessment (handlers.rs:400)

WicAssessmentId

symmetric req/resp DTO; .into() at construction

schedule_appointment (appointment_handlers.rs:88)

HouseholdId (re-scope)

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

get_determination (162), list_authorizations_for_determination (322)

DeterminationId

get_authorization (265), update_authorization (347), switch_provider (378)

CapsAuthorizationId

get_provider/update_provider/delete_provider (providers.rs:{90,114,154})

CapsProviderId

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

get_determination (152), get_explanation (322), requeue_determination (cmd_handlers.rs:113)

DeterminationId

DTO id stays Uuid

get_eligible_categories (286)

MedicaidApplicationId

inline SQL binds medicaid_application_id; file URL follow-up

get_ele_status (390), revoke_ele (466)

PersonId

boundary .into_inner()

get_ele_household_summary (581)

HouseholdId

boundary .into_inner()

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

get_determination (handlers.rs:222) + get_determination_explanation (work_requirement_handlers.rs:632)

DeterminationId

different files; one shared store fn

resolve_discrepancy (discrepancy_handlers.rs:79)

TanfDiscrepancyId

tanf-owned table, NOT snap’s IEVS

update_personal_responsibility (personal_responsibility_handlers.rs:116)

TanfPersonalResponsibilityId

full thread

PR create (:42) + list (:69) Path(application_id)

TanfApplicationId

FK is tanf_applications.id, not shared ApplicationId; list_personal_responsibilities also called from determine.rs (+1 ripple → 3 determine.rs .into() total)

grg list_grg_payments (grg_handlers.rs:87) + work-req get_work_requirements(45)/log_activity(71)/list_activities(210)/activities_summary(299)/get_time_limits(599) — all Path(person_id)

PersonId

6 sites; get_work_requirements/get_time_limits also called from determine.rs (2 ripples)

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 create_assignment(57)/list_assignments_by_worker(114) Path(worker_id)

Uuid (unchanged — Step-11 allowlist)

assignments delete_assignment(84)

HouseholdAssignmentId

assignments list_assignments_by_household(139)

HouseholdId

documents upload_document(104)/list_documents(284)

ApplicationId

documents get_document_content(313)/accept_document(356)/reject_document(393) Path<(Uuid,Uuid)>

(ApplicationId, DocumentId)

recovery recover_get(211) Path<uuid::Uuid>

RecoveryId

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

close_household_member_claim (mod.rs:813)

HouseholdMemberId

close_member_version takes it; the close event gets .into_inner(); require_member_ownership stays Uuid (shared with the claim/correction path claim_household_member whose fact_id is a request-body Uuid — sibling of require_fact_ownership) → .into_inner() at the close call

close_address_claim (mod.rs:949)

AddressId

close_address_version takes it; shared require_fact_ownership + the close event get .into_inner()

close_income_claim (mod.rs:1364)

IncomeId

close_income_version takes it; shared helper + the close event get .into_inner()

post_redact_fact (mod.rs:1462)

FactId (generic)

runtime-polymorphic {kind}; require_fact_ownership/shred_with/the redact event get .into_inner(); also type RedactFactResponse.fact_idFactId (contracts-persons/src/redaction.rs:40; drop the now-unused use uuid::Uuid) so persons OpenAPI regenerates a $ref + new FactId component (wire-identical → api-docs --update); response construction is direct (extractor + DTO field are both FactId)

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.tomlthe 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)

  1. Branch feature/627-typed-ids-{slug} off main.

  2. Mint the step’s newtype(s); add the canopy-common dep to the contract crate if listed.

  3. Apply the Threading rule (three tiers) map (+ the Approved exceptions where noted); new .rs files get the SPDX header.

  4. Gates: cargo check --workspace --all-targetscargo clippy -p <touched> --all-targets --profile test — -D warningscargo xtask api-docs (needs a dev refresh first 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 $ref is the failure to fix, never --update (see the transparency + The utoipa params() doc-type stays uuid::Uuid (extractor only is typed) notes) → cargo xtask quality-budgets --fail-on-regressioncargo fmt --all.

  5. CHANGELOG.adoc === Changed entry; one fresh J1–J8 pre-commit subagent review (the reflection gate in .claude/rules/pre-commit-token-protocol.md).

  6. Commit refactor(<svc>): … (#627) + a Co-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 with Relates to #627 for Steps 0–10).

  7. 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.

  8. 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.adoc entry to Archive.

Files Touched (representative)

File(s) Change

crates/canopy-common/src/id.rs

+15 define_id! newtypes (no hand-rolled worker type — Step 8 decision)

crates/canopy-contracts-applications/{assignments,documents}.rs

Step 8: type entity ids (documents DTO → OpenAPI regen; worker_id stays Uuid)

services/canopy-enrollment/src/{clients,api}/mod.rs + canopy-web doc test literal

Step 8: household-access gate household_id; ApplicationDocument literal .into()

crates/canopy-common/src/fti_audit.rs

type FtiAuditEntry.id + trait/impl (Step 1)

crates/canopy-overpayments/src/lib.rs + Cargo.toml

+dep; full id/FK typing (Step 2)

services/canopy-{reporting,snap,tanf,medicaid}/…

overpayments consumer updates (Step 2)

services/canopy-{svc}/src/api/.rs + store/.rs

Path + store fn + row-mirror per step

crates/canopy-contracts-{caps,wic,tanf}/

+dep; type entity .id

services/canopy-web/src/api/actions_wic.rs + wic route

appointment re-scope (Step 3)

xtask/src/cmd/typed_ids.rs (new,SPDX) + main.rs + cmd/mod.rs + validate.rs ([9b/15]) + .gitlab-ci.yml

Step 11 lint + CI mirror (typed-id-path-audit)

compliance/typed-id-path-allowlist.toml (new; the 2 worker-Path sites)

Step 11

docs/modules/ROOT/pages/plans/typed-path-id-rollout.adoc + nav.adoc

Step 0

CHANGELOG.adoc

=== Changed per step

Verification

  1. cargo check --workspace --all-targets clean.

  2. cargo clippy touched --profile test — -D warnings clean.

  3. cargo xtask api-docs snapshots match (transparency).

  4. cargo xtask quality-budgets --fail-on-regression passes.

  5. Full pre-push battery green per slice.

  6. Step 11: the lint fails on an injected Path<Uuid>/Path<uuid::Uuid>/tuple form and passes on the tree.

  7. Step 3: exercise the re-scoped appointment route end-to-end (wic appointment integration test + the #392 BFF action).

Documentation Updates

  • CHANGELOG.adoc === Changed per 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}/categories URL mismatch; overpayments_handler.rs per-service duplication (DRY); #571 review (superseded by Step 3’s re-scope); household_assignments.id + recovery_pending.id PK defaults are gen_random_uuid() (v4) — migrate to v7 per the UUID-v7 rule (pre-existing; the typed-ID change keeps the Uuid column, so this is separate).

Edit this page · default