Plan: CAPS Provider Registry (Issue #396)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Migration. New |
Done (2026-05-12) |
2 |
Models. Update |
Done (2026-05-12) |
3 |
Store. Update |
Done (2026-05-12) |
4 |
API. New |
Done (2026-05-12) |
5 |
Authorization handler refactor. The existing |
Done (2026-05-12) |
6 |
Tests + docs. 8 unit tests (5 CRUD on providers + 3 FK enforcement on authorizations). 1 integration test asserting that creating an authorization with a non-existent provider_id returns 422 via |
Done (2026-05-12) |
Issue: #396
Branch: feat/caps-provider-registry
Labels: type::feature, priority::low, service::caps, program::caps, workflow::ready
Context
services/canopy-caps/migrations/20260413000000_create_caps_tables.sql:12,43,68 declares provider_id as a bare TEXT column on both caps_applications (line 12, Option<TEXT> at intake) and caps_authorizations (line 43, NOT NULL TEXT once authorized) — neither carries a foreign-key constraint and neither validates the string. Line 68 indexes the authorizations column. Nothing prevents bad data from landing on either table, and the two tables can drift on which provider string is "the truth" for a given child. CAPS authorizations go straight into the database with whatever string the API caller hands over, which is a problem when the authorization is later used to drive payments.
Per the architectural decision locked 2026-05-05, the right fix is a caps_providers table inside canopy-caps (no separate canopy-providers service yet — that would be premature abstraction). The pre-1.0 status of canopy means the migration can drop the old columns directly without expand-contract / backfill. Both columns get the same TEXT → UUID + FK treatment so the schema stays internally consistent.
Code references
-
services/canopy-caps/migrations/20260413000000_create_caps_tables.sql:12,43,68— bare TEXTprovider_idoncaps_applications(line 12) andcaps_authorizations(line 43, indexed at line 68). -
services/canopy-caps/src/store/models.rs:17,50—CapsApplication.provider_id: Option<String>(line 17) andCapsAuthorization.provider_id: String(line 50). -
services/canopy-caps/src/store/authorizations.rs:8-35—create_authorizationto refactor. -
services/canopy-caps/src/api/mod.rs— Router to extend. -
crates/canopy-common/src/error.rs—ApiError::UnprocessableEntity(String)variant (HTTP 422), added in MR !248.
Scope
In scope:
-
caps_providerstable. -
CRUD endpoints for providers.
-
FK constraint on
caps_authorizations.provider_id. -
Soft-delete (status = inactive) on providers.
Out of scope:
-
Cross-service provider directory. CAPS is the sole consumer for now; if/when Medicaid or other programs need shared provider records, that’s a separate
canopy-providersservice plan. -
Provider-side credentialing / license verification automation. The license_number / license_expires fields exist in the schema but verification stays a manual workflow until/unless an external credentialing API is in scope.
-
Provider hierarchy (parent organization, subsidiary, etc.). Single-row records only.
-
Soft-deleted provider revival. Once status = inactive, providers stay that way unless a new row is created.
-
Payment integration with provider records. Payment routing is a downstream concern.
Dependencies
-
No prerequisite plans on disk.
-
worker-portal-program-action-handlers.adoc(#392)switch_provider_capshandler validates against this registry; landing order: this plan first, then #392 picks up.
Design
caps_providers schema:
CREATE TABLE caps_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_code TEXT UNIQUE NOT NULL,
legal_name TEXT NOT NULL,
doing_business_as TEXT,
ein TEXT,
license_number TEXT,
license_type TEXT,
license_expires DATE,
status TEXT NOT NULL DEFAULT 'active',
contact_email TEXT,
contact_phone TEXT,
address_line1 TEXT,
address_line2 TEXT,
city TEXT,
state TEXT,
postal_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX caps_providers_active ON caps_providers (status) WHERE status = 'active';
caps_applications + caps_authorizations migration (forward-only, drops old columns from both tables):
-- Drop the bare TEXT columns (pre-1.0, no historical data preservation).
-- Drop the matching index on caps_authorizations.provider_id first.
DROP INDEX IF EXISTS idx_caps_authorizations_provider;
ALTER TABLE caps_applications DROP COLUMN provider_id;
ALTER TABLE caps_authorizations DROP COLUMN provider_id;
-- Add the new UUID FK columns.
-- caps_applications: nullable (provider may not be selected at intake).
ALTER TABLE caps_applications
ADD COLUMN provider_id UUID NULL REFERENCES caps_providers(id);
-- caps_authorizations: required (authorization implies a selected provider).
ALTER TABLE caps_authorizations
ADD COLUMN provider_id UUID NOT NULL REFERENCES caps_providers(id);
CREATE INDEX idx_caps_authorizations_provider ON caps_authorizations(provider_id);
CREATE INDEX idx_caps_applications_provider ON caps_applications(provider_id)
WHERE provider_id IS NOT NULL;
(If caps_applications or caps_authorizations has any existing rows in dev DBs, this fails — operator runs cargo xtask migrate clean caps to reset before rerun. Documented in CHANGELOG since the dev tear-down requirement is the only contributor-visible impact.)
CRUD endpoints follow the canopy-caps API conventions; mirror services/canopy-caps/src/api/authorizations.rs shape.
Files Touched
| File | Change |
|---|---|
|
New migration (drops |
|
Update |
|
New store module |
|
Update |
|
New API module |
|
FK violations surface as |
|
Register routes + ApiDoc components (route count 5 → 10) |
|
Regenerated |
|
canopy-caps route + table updates |
|
|
Verification
-
cargo nextest run -p canopy-caps— unit tests pass. -
cargo xtask api-docs— snapshot regenerates clean; canopy-caps route count goes from 5 to 10. -
cargo xtask migrate runagainst fresh dev DB — migration succeeds (bothcaps_applicationsandcaps_authorizationsend up with UUID FK provider_id columns). -
cargo xtask dev start && cargo nextest run -p canopy-caps --test providers_test --run-ignored only— integration test passes; FK violation rejected viaApiError::UnprocessableEntity(422). -
Manual smoke: POST a provider, POST an authorization referencing it, confirm join works; POST an authorization with a fake UUID, confirm 422 with the canonical error body shape.
-
cargo xtask validate— full battery green.
Documentation Updates
-
.claude/docs/services.md— canopy-caps route + table updates -
CHANGELOG.adoc— entry under== Unreleased/=== Changed; note dev DB tear-down -
docs/modules/ROOT/pages/services/canopy-caps.adoc— add provider registry section -
Plan archive: move to
plans/archive/post-merge