Plan: CAPS Provider Registry (Issue #396)

On this page

Status

Step Description Status

1

Migration. New services/canopy-caps/migrations/20260511000000_add_caps_providers.sql. Creates caps_providers table; drops the old caps_applications.provider_id TEXT and caps_authorizations.provider_id TEXT columns (pre-1.0, no historical data per architectural decision 2026-05-05); adds new caps_applications.provider_id UUID NULL REFERENCES caps_providers(id) (nullable — provider may not be selected at application intake) and caps_authorizations.provider_id UUID NOT NULL REFERENCES caps_providers(id) columns. Forward-only per ADR-016.

Done (2026-05-12)

2

Models. Update services/canopy-caps/src/store/models.rs:17,50 to flip caps_applications.provider_id: Option<String>Option<Uuid> and caps_authorizations.provider_id: StringUuid. Add new CapsProvider struct with all the registry fields.

Done (2026-05-12)

3

Store. Update services/canopy-caps/src/store/authorizations.rs:8-35 (create_authorization) to bind the UUID FK. Update any caps_applications insert path to bind Option<Uuid>. New services/canopy-caps/src/store/providers.rs with create, get, list_active, update, mark_inactive (soft-delete via status field, no hard delete to preserve referential integrity for historical authorizations).

Done (2026-05-12)

4

API. New services/canopy-caps/src/api/providers.rs exposing POST /v1/providers, GET /v1/providers/{id}, PUT /v1/providers/{id}, DELETE /v1/providers/{id} (sets status to inactive), GET /v1/providers?status=active. Register routes in services/canopy-caps/src/api/mod.rs. utoipa annotations on all 5 endpoints.

Done (2026-05-12)

5

Authorization handler refactor. The existing create_authorization handler now validates the incoming provider_id is a UUID (handled by serde via the Uuid type) and lets the FK constraint do referential validation at insert time. Reject FK violations as ApiError::UnprocessableEntity (HTTP 422) from canopy_common::error with a clear message — the variant added in MR !248 (2026-05-10) is the canonical mechanism for semantically-invalid input across canopy services.

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 ApiError::UnprocessableEntity. Update .claude/docs/services.md (canopy-caps route count goes from 5 to 10). CHANGELOG === Changed (bare TEXT → FK). OpenAPI sync. Plan archives.

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 TEXT provider_id on caps_applications (line 12) and caps_authorizations (line 43, indexed at line 68).

  • services/canopy-caps/src/store/models.rs:17,50CapsApplication.provider_id: Option<String> (line 17) and CapsAuthorization.provider_id: String (line 50).

  • services/canopy-caps/src/store/authorizations.rs:8-35create_authorization to refactor.

  • services/canopy-caps/src/api/mod.rs — Router to extend.

  • crates/canopy-common/src/error.rsApiError::UnprocessableEntity(String) variant (HTTP 422), added in MR !248.

  • ADR-016 — Forward-only migrations

Scope

In scope:

  • caps_providers table.

  • 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-providers service 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_caps handler 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

services/canopy-caps/migrations/20260511000000_add_caps_providers.sql

New migration (drops caps_applications.provider_id + caps_authorizations.provider_id TEXT columns and re-adds them as UUID FKs)

services/canopy-caps/src/store/models.rs

Update provider_id types on both CapsApplication (line 17) and CapsAuthorization (line 50); add CapsProvider

services/canopy-caps/src/store/providers.rs

New store module

services/canopy-caps/src/store/authorizations.rs

Update create_authorization to UUID FK (and parallel update on any caps_applications insert path)

services/canopy-caps/src/api/providers.rs

New API module

services/canopy-caps/src/api/handlers.rs

FK violations surface as ApiError::UnprocessableEntity (HTTP 422)

services/canopy-caps/src/api/mod.rs

Register routes + ApiDoc components (route count 5 → 10)

docs/modules/ROOT/openapi/canopy-caps.json

Regenerated

.claude/docs/services.md

canopy-caps route + table updates

CHANGELOG.adoc

=== Changed entry; note dev DB tear-down requirement

Verification

  1. cargo nextest run -p canopy-caps — unit tests pass.

  2. cargo xtask api-docs — snapshot regenerates clean; canopy-caps route count goes from 5 to 10.

  3. cargo xtask migrate run against fresh dev DB — migration succeeds (both caps_applications and caps_authorizations end up with UUID FK provider_id columns).

  4. cargo xtask dev start && cargo nextest run -p canopy-caps --test providers_test --run-ignored only — integration test passes; FK violation rejected via ApiError::UnprocessableEntity (422).

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

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

Edit this page · default