Case Management & Placement Feature Completion

On this page

Status

Step Description Status

1

Case notes and narrative documentation

Done (pre-ADR-030) — contacts table, 6 endpoints, UI tab

2

Automated data quality monitoring

Done (pre-ADR-030) — data_quality_issues table, dashboard endpoint, Rules Engine

3

Title IV-E eligibility via rules engine

Done (pre-ADR-030) — GA + TX rule sets, eligibility evaluation endpoint

4

Reunification and permanency planning

Done (pre-ADR-030) — case plan goals, milestones, timeliness monitoring

5

Educational enrollment tracking

Done (2026-03-20) — MR !45

6

Health and developmental records

Done (2026-03-20) — MR !45

7

Major changes monitoring (45 CFR 272.15)

Done (2026-03-20) — MR !45

Epic: &11
Issues: #68–#74
Branch: feature/case-placement-completion

Context

Phases 3 (Case Management) and 4 (Placement) are structurally complete with CRUD, events, and state machines. Steps 1–4 were implemented during initial development but the plan and roadmap were not updated to reflect completion. Three compliance-relevant features remain: educational enrollment tracking, health and developmental records, and major changes monitoring.

All three follow established CRAIG patterns: sqlx::FromRow model structs, paginated list + CRUD store functions, Axum handlers with Extension<Claims> auth, Publisher event publishing, Askama web templates, and clap CLI subcommands.

Completed Work (Steps 1–4)

Step 1: Case Notes — COMPLETE

  • contacts table with narrative TEXT field, contact_type, duration_minutes, occurred_by

  • contact_attachments table for file uploads via craig-store

  • 6 API endpoints: create/list contacts, upload/list/download/delete attachments

  • Web UI: Contacts tab on case detail page

  • Tests: integration tests cover CRUD and attachment workflows

Step 2: Data Quality Monitoring — COMPLETE

  • data_quality_issues table in craig-reporting: issue_type, field_name, severity, resolved tracking

  • data_quality_metrics table for point-in-time dashboard data

  • GET /v1/reporting/quality/dashboard endpoint (supervisor+)

  • Rules Engine continuously evaluates cases and creates issues on validation failure

Step 3: Title IV-E Eligibility — COMPLETE

  • JDM rule sets: georgia-ive-eligibility.json (8 decision tables, FFP 0.6736), texas-ive-eligibility.json (8 tables, FFP 0.6146)

  • Evaluation via POST /v1/rules/evaluate with eligibility context

  • Financial service tracks ive_eligible, ffp_rate, ffp_amount on payments

Step 4: Reunification & Permanency — COMPLETE

  • case_plans table: permanency_goal field (reunification, adoption, guardianship, APPLA, relative_placement)

  • case_plan_tasks for action items assigned to Parent/Agency/Provider/Other

  • case_milestones auto-generated by Rules Engine: permanency_hearing (365d), case_plan_review (180d), redetermination

  • Timeliness rule sets monitor ASFA deadlines

Remaining Work (Steps 5–7)

Scope

In scope:

  • Educational enrollment CRUD + UI + CLI (craig-placement)

  • Health and developmental records CRUD + UI + CLI (craig-placement)

  • Major changes monitoring CRUD + UI + CLI (craig-security)

  • Seed data for all three new tables (RNG-stable: use inline self.pick() + post-creation _name derivation)

  • Worker display name columns (recorded_by_name, reported_by_name) from the start

Out of scope:

  • External system adapters (education, health, Medicaid) — tracked in exchange-adapters plan

  • Reporting integrations for new data — covered by craig-reporting

  • Automated overdue alerting via Rules Engine — future enhancement

Step 5: Educational Enrollment Tracking

Service: craig-placement
Federal requirement: § 1355.52(e-f) — education system data exchange

5a. Database Migration

File: services/craig-placement/migrations/20260321000000_education_records.sql

CREATE TABLE education_records (
    id                UUID PRIMARY KEY DEFAULT uuidv7(),
    child_id          UUID NOT NULL,
    placement_id      UUID REFERENCES placements(id),
    school_name       TEXT NOT NULL,
    school_district   TEXT,
    school_type       TEXT NOT NULL CHECK (school_type IN ('public', 'private', 'charter', 'homeschool', 'virtual')),
    grade_level       TEXT,
    enrollment_date   DATE NOT NULL,
    withdrawal_date   DATE,
    withdrawal_reason TEXT CHECK (withdrawal_reason IS NULL OR withdrawal_reason IN ('transfer', 'graduation', 'aging_out', 'placement_change', 'other')),
    iep_flag          BOOLEAN NOT NULL DEFAULT false,
    section_504       BOOLEAN NOT NULL DEFAULT false,
    notes             TEXT,
    recorded_by       TEXT NOT NULL,
    recorded_by_name  TEXT,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    active            BOOLEAN NOT NULL DEFAULT true,
    deleted_at        TIMESTAMPTZ
);

CREATE INDEX idx_education_child ON education_records(child_id) WHERE active = true;
CREATE INDEX idx_education_placement ON education_records(placement_id) WHERE active = true;

5b. Store Model

File: services/craig-placement/src/store/models.rs — add:

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct EducationRecord {
    pub id: Uuid,
    pub child_id: Uuid,
    pub placement_id: Option<Uuid>,
    pub school_name: String,
    pub school_district: Option<String>,
    pub school_type: String,
    pub grade_level: Option<String>,
    pub enrollment_date: NaiveDate,
    pub withdrawal_date: Option<NaiveDate>,
    pub withdrawal_reason: Option<String>,
    pub iep_flag: bool,
    pub section_504: bool,
    pub notes: Option<String>,
    pub recorded_by: String,
    pub recorded_by_name: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub active: bool,
    pub deleted_at: Option<DateTime<Utc>>,
}

5c. Store Functions

File: services/craig-placement/src/store/education.rs — new module.

Follow the foster_home_training pattern (CRUD + list with child_id filter).

pub async fn create_education_record(
    pool: &PgPool,
    child_id: Uuid,
    placement_id: Option<Uuid>,
    school_name: &str,
    school_district: Option<&str>,
    school_type: &str,
    grade_level: Option<&str>,
    enrollment_date: NaiveDate,
    iep_flag: bool,
    section_504: bool,
    notes: Option<&str>,
    recorded_by: &str,
    recorded_by_name: &str,
) -> anyhow::Result<EducationRecord>
// SQL: INSERT INTO education_records (id, child_id, placement_id, school_name, school_district,
//   school_type, grade_level, enrollment_date, iep_flag, section_504, notes, recorded_by,
//   recorded_by_name) VALUES (uuidv7(), $1..$13) RETURNING *

pub async fn get_education_record(
    pool: &PgPool,
    id: Uuid,
) -> anyhow::Result<Option<EducationRecord>>
// SQL: SELECT * FROM education_records WHERE id = $1

pub async fn list_education_records(
    pool: &PgPool,
    child_id: Uuid,
    limit: i64,
    offset: i64,
) -> anyhow::Result<Vec<EducationRecord>>
// SQL: SELECT * FROM education_records WHERE child_id = $1 AND active = true
//   ORDER BY enrollment_date DESC LIMIT $2 OFFSET $3

pub async fn count_education_records(
    pool: &PgPool,
    child_id: Uuid,
) -> anyhow::Result<i64>
// SQL: SELECT COUNT(*) FROM education_records WHERE child_id = $1 AND active = true

pub async fn update_education_record(
    pool: &PgPool,
    id: Uuid,
    school_name: Option<&str>,
    school_district: Option<Option<&str>>,
    school_type: Option<&str>,
    grade_level: Option<Option<&str>>,
    withdrawal_date: Option<Option<NaiveDate>>,
    withdrawal_reason: Option<Option<&str>>,
    iep_flag: Option<bool>,
    section_504: Option<bool>,
    notes: Option<Option<&str>>,
) -> anyhow::Result<Option<EducationRecord>>
// SQL: UPDATE education_records SET school_name = COALESCE($2, school_name), ...
//   updated_at = now() WHERE id = $1 AND active = true RETURNING *

pub async fn delete_education_record(
    pool: &PgPool,
    id: Uuid,
) -> anyhow::Result<Option<EducationRecord>>
// SQL: UPDATE education_records SET active = false, deleted_at = now()
//   WHERE id = $1 AND active = true RETURNING *

Register module: add pub mod education; to services/craig-placement/src/store/mod.rs.

5d. API Request/Response Types

File: services/craig-placement/src/api/education.rs — new module.

#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateEducationRequest {
    pub child_id: Uuid,
    pub placement_id: Option<Uuid>,
    pub school_name: String,
    pub school_district: Option<String>,
    pub school_type: String,           // validated: public, private, charter, homeschool, virtual
    pub grade_level: Option<String>,
    pub enrollment_date: NaiveDate,
    pub iep_flag: Option<bool>,        // defaults to false
    pub section_504: Option<bool>,     // defaults to false
    pub notes: Option<String>,
}

#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateEducationRequest {
    pub school_name: Option<String>,
    pub school_district: Option<Option<String>>,
    pub school_type: Option<String>,
    pub grade_level: Option<Option<String>>,
    pub withdrawal_date: Option<Option<NaiveDate>>,
    pub withdrawal_reason: Option<Option<String>>,
    pub iep_flag: Option<bool>,
    pub section_504: Option<bool>,
    pub notes: Option<Option<String>>,
}

#[derive(Deserialize)]
pub struct ListEducationQuery {
    pub child_id: Uuid,
    #[serde(default = "default_page")]
    pub page: u32,
    #[serde(default = "default_per_page")]
    pub per_page: u32,
}

5d. API Handlers

// POST /v1/placement/education — create education record
pub async fn create_education(
    State(app): State<AppState>,
    Extension(claims): Extension<Claims>,
    Extension(publisher): Extension<Publisher>,
    Json(body): Json<CreateEducationRequest>,
) -> Result<Json<EducationRecord>, ApiError>
// Validate: claims.require_caseworker_or_above()?, validate school_type enum
// Store: create_education_record(pool, body.child_id, ..., &claims.sub, &worker_name)
// Event: publish_education_created(publisher, record.id, body.child_id)

// GET /v1/placement/education?child_id={uuid} — list by child
pub async fn list_education(
    State(app): State<AppState>,
    Extension(claims): Extension<Claims>,
    Query(query): Query<ListEducationQuery>,
) -> Result<Json<PageResponse<EducationRecord>>, ApiError>
// Validate: claims.require_caseworker_or_above()?
// Store: count + list_education_records

// GET /v1/placement/education/{id} — get by ID
pub async fn get_education(
    State(app): State<AppState>,
    Extension(claims): Extension<Claims>,
    Path(id): Path<Uuid>,
) -> Result<Json<EducationRecord>, ApiError>
// Validate: claims.require_caseworker_or_above()?
// Store: get_education_record, return 404 if None

// PUT /v1/placement/education/{id} — update
pub async fn update_education(
    State(app): State<AppState>,
    Extension(claims): Extension<Claims>,
    Extension(publisher): Extension<Publisher>,
    Path(id): Path<Uuid>,
    Json(body): Json<UpdateEducationRequest>,
) -> Result<Json<EducationRecord>, ApiError>
// Validate: claims.require_caseworker_or_above()?, validate school_type if provided
// Store: update_education_record, return 404 if None
// Event: publish_education_updated(publisher, id)

// DELETE /v1/placement/education/{id} — soft-delete
pub async fn delete_education(
    State(app): State<AppState>,
    Extension(claims): Extension<Claims>,
    Path(id): Path<Uuid>,
) -> Result<Json<EducationRecord>, ApiError>
// Validate: claims.require_supervisor_or_above()?
// Store: delete_education_record, return 404 if None

5e. Validation

File: services/craig-placement/src/api/validation.rs — add:

const VALID_SCHOOL_TYPES: &[&str] = &["public", "private", "charter", "homeschool", "virtual"];
const VALID_WITHDRAWAL_REASONS: &[&str] = &["transfer", "graduation", "aging_out", "placement_change", "other"];

pub fn validate_school_type(v: &str) -> Result<(), String> {
    if VALID_SCHOOL_TYPES.contains(&v) { Ok(()) }
    else { Err(format!("invalid school_type: {v}")) }
}

pub fn validate_withdrawal_reason(v: &str) -> Result<(), String> {
    if VALID_WITHDRAWAL_REASONS.contains(&v) { Ok(()) }
    else { Err(format!("invalid withdrawal_reason: {v}")) }
}

5f. Error Cases

  • POST with invalid school_type → 400 Bad Request ("invalid school_type: xyz")

  • POST with missing school_name or enrollment_date → 400 (serde deserialization)

  • PUT with invalid withdrawal_reason → 400

  • GET /{id} with nonexistent ID → 404 Not Found

  • DELETE /{id} without supervisor+ role → 403 Forbidden

  • DELETE /{id} on already-deleted record → 404

5g. Route Registration

File: services/craig-placement/src/api/mod.rs — add to routes():

.route("/placement/education", get(education::list_education).post(education::create_education))
.route("/placement/education/{id}", get(education::get_education).put(education::update_education).delete(education::delete_education))

5h. Events

File: services/craig-placement/src/events.rs — add:

pub async fn publish_education_created(publisher: &Publisher, record_id: Uuid, child_id: Uuid) {
    let envelope = EventEnvelope::new(
        "craig-placement",
        "placement.education.created",
        serde_json::json!({ "education_record_id": record_id, "child_id": child_id }),
    );
    if let Err(e) = publisher.publish(&envelope).await {
        tracing::warn!(error = %e, "failed to publish placement.education.created");
    }
}

pub async fn publish_education_updated(publisher: &Publisher, record_id: Uuid) {
    let envelope = EventEnvelope::new(
        "craig-placement",
        "placement.education.updated",
        serde_json::json!({ "education_record_id": record_id }),
    );
    if let Err(e) = publisher.publish(&envelope).await {
        tracing::warn!(error = %e, "failed to publish placement.education.updated");
    }
}

5i. CLI Commands

File: services/craig-cli/src/cmd/education.rs — new module.

#[derive(Subcommand)]
pub enum EducationCmd {
    /// List education records for a child
    List {
        #[arg(long)]
        child_id: String,
        #[arg(long, default_value = "1")]
        page: u32,
        #[arg(long, default_value = "25")]
        per_page: u32,
    },
    /// Get education record by ID
    Get { id: String },
    /// Create education record
    Create {
        #[arg(long)]
        child_id: String,
        #[arg(long)]
        school_name: String,
        #[arg(long)]
        school_type: String,
        #[arg(long)]
        enrollment_date: String,
        #[arg(long)]
        placement_id: Option<String>,
        #[arg(long)]
        school_district: Option<String>,
        #[arg(long)]
        grade_level: Option<String>,
        #[arg(long)]
        iep: bool,
        #[arg(long)]
        section_504: bool,
    },
    /// Update education record
    Update {
        id: String,
        #[arg(long)]
        withdrawal_date: Option<String>,
        #[arg(long)]
        withdrawal_reason: Option<String>,
        #[arg(long)]
        school_name: Option<String>,
        #[arg(long)]
        school_type: Option<String>,
    },
    /// Delete (soft-delete) education record
    Delete { id: String },
}

Register in services/craig-cli/src/cmd/mod.rs and services/craig-cli/src/main.rs.

5j. Web UI

File: services/craig-web/templates/placement/education_list.html — new page listing education records for a child, linked from placement detail.

Route: GET /placement/education?child_id={uuid} in services/craig-web/src/routes/placement.rs

View model:

#[allow(dead_code)]
#[derive(Deserialize, Default, Clone)]
pub struct EducationView {
    pub id: Uuid,
    pub child_id: Uuid,
    pub school_name: String,
    pub school_type: String,
    pub grade_level: Option<String>,
    pub enrollment_date: String,
    pub withdrawal_date: Option<String>,
    pub withdrawal_reason: Option<String>,
    pub iep_flag: bool,
    pub section_504: bool,
    pub recorded_by_name: Option<String>,
    pub recorded_by: String,
}

Template: data table with columns School Name, Type, Grade, Enrolled, Withdrawn, IEP, 504, Recorded By. Add "New Record" button linking to an inline form (Alpine.js toggle, same pattern as training records on home_detail.html).

5k. Seed Data

Files: tools/craig-seed/src/model.rs, datagen.rs, sql.rs

Generate 1-2 education records per child in placed families. Use inline self.pick() for recorded_by to preserve RNG stability:

fn make_education_record(&mut self, child_id: Uuid, placement_id: Option<Uuid>) -> EducationRecord {
    let school_type = self.pick(SCHOOL_TYPES).to_string();
    let grade = format!("{}", self.rng.random_range(1..=12));
    let enrolled = self.random_date_2024();

    let mut r = EducationRecord {
        id: self.uuids.next(),
        child_id,
        placement_id,
        school_name: format!("{} {} School", self.pick(GEORGIA_ADMIN_UNITS), if self.rng.random_bool(0.5) { "Elementary" } else { "Middle" }),
        school_district: Some(format!("{} County Schools", self.pick(GEORGIA_ADMIN_UNITS))),
        school_type,
        grade_level: Some(grade),
        enrollment_date: enrolled,
        withdrawal_date: None,
        withdrawal_reason: None,
        iep_flag: self.rng.random_bool(0.15),
        section_504: self.rng.random_bool(0.10),
        notes: None,
        recorded_by: self.pick(WORKERS).to_string(),
        recorded_by_name: String::new(),
    };
    r.recorded_by_name = worker_display_name(&r.recorded_by);
    r
}

SQL renderer: add INSERT INTO education_records (…​) in render_placement_sql().

5l. Integration Tests

File: services/craig-placement/tests/api/education.rs — new test file.

Tests (6 total):

  1. create_education_record — POST with valid data returns 200 with all fields

  2. get_education_record — GET /{id} returns the created record

  3. list_education_by_child — GET ?child_id= returns records, respects pagination

  4. update_education_record — PUT with withdrawal_date and reason returns updated record

  5. delete_education_record — DELETE returns soft-deleted record, subsequent list excludes it

  6. create_education_invalid_school_type — POST with invalid school_type returns 400

  7. delete_requires_supervisor — DELETE as caseworker returns 403

Register in services/craig-placement/tests/api/mod.rs.

Step 6: Health and Developmental Records

Service: craig-placement
Federal requirement: § 1355.52(e-f) — health agency data exchange

Follows the identical pattern as Step 5. Only the table schema, validation, and field names differ.

6a. Database Migration

File: services/craig-placement/migrations/20260321000001_health_records.sql

CREATE TABLE health_records (
    id                UUID PRIMARY KEY DEFAULT uuidv7(),
    child_id          UUID NOT NULL,
    record_type       TEXT NOT NULL CHECK (record_type IN ('physical_exam', 'dental', 'vision', 'mental_health', 'developmental', 'immunization')),
    provider_name     TEXT,
    provider_type     TEXT CHECK (provider_type IS NULL OR provider_type IN ('pediatrician', 'dentist', 'optometrist', 'therapist', 'specialist', 'nurse')),
    visit_date        DATE NOT NULL,
    next_due_date     DATE,
    diagnosis         TEXT,
    treatment_plan    TEXT,
    medications       TEXT,
    immunization_name TEXT,
    immunization_date DATE,
    notes             TEXT,
    recorded_by       TEXT NOT NULL,
    recorded_by_name  TEXT,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    active            BOOLEAN NOT NULL DEFAULT true,
    deleted_at        TIMESTAMPTZ
);

CREATE INDEX idx_health_child ON health_records(child_id) WHERE active = true;
CREATE INDEX idx_health_due ON health_records(next_due_date) WHERE active = true AND next_due_date IS NOT NULL;

6b. Store Model

File: services/craig-placement/src/store/models.rs — add:

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct HealthRecord {
    pub id: Uuid,
    pub child_id: Uuid,
    pub record_type: String,
    pub provider_name: Option<String>,
    pub provider_type: Option<String>,
    pub visit_date: NaiveDate,
    pub next_due_date: Option<NaiveDate>,
    pub diagnosis: Option<String>,
    pub treatment_plan: Option<String>,
    pub medications: Option<String>,
    pub immunization_name: Option<String>,
    pub immunization_date: Option<NaiveDate>,
    pub notes: Option<String>,
    pub recorded_by: String,
    pub recorded_by_name: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub active: bool,
    pub deleted_at: Option<DateTime<Utc>>,
}

6c. Store Functions

File: services/craig-placement/src/store/health.rs — new module.

Same pattern as education store. Functions:

  • create_health_record(pool, child_id, record_type, provider_name, provider_type, visit_date, next_due_date, diagnosis, treatment_plan, medications, immunization_name, immunization_date, notes, recorded_by, recorded_by_name) → Result<HealthRecord>

  • get_health_record(pool, id) → Result<Option<HealthRecord>>

  • list_health_records(pool, child_id, limit, offset) → Result<Vec<HealthRecord>>

  • count_health_records(pool, child_id) → Result<i64>

  • update_health_record(pool, id, …​) → Result<Option<HealthRecord>>

  • delete_health_record(pool, id) → Result<Option<HealthRecord>>

  • list_overdue_health_records(pool, limit, offset) → Result<Vec<HealthRecord>>

The overdue query:

SELECT * FROM health_records
WHERE active = true
  AND next_due_date IS NOT NULL
  AND next_due_date < CURRENT_DATE
ORDER BY next_due_date ASC
LIMIT $1 OFFSET $2
  • count_overdue_health_records(pool) → Result<i64>

6d. API Request/Response Types

File: services/craig-placement/src/api/health.rs — new module.

#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateHealthRequest {
    pub child_id: Uuid,
    pub record_type: String,          // validated: physical_exam, dental, vision, mental_health, developmental, immunization
    pub provider_name: Option<String>,
    pub provider_type: Option<String>, // validated if present
    pub visit_date: NaiveDate,
    pub next_due_date: Option<NaiveDate>,
    pub diagnosis: Option<String>,
    pub treatment_plan: Option<String>,
    pub medications: Option<String>,
    pub immunization_name: Option<String>,  // required when record_type = immunization
    pub immunization_date: Option<NaiveDate>,
    pub notes: Option<String>,
}

6e. Validation

const VALID_RECORD_TYPES: &[&str] = &["physical_exam", "dental", "vision", "mental_health", "developmental", "immunization"];
const VALID_PROVIDER_TYPES: &[&str] = &["pediatrician", "dentist", "optometrist", "therapist", "specialist", "nurse"];

Additional validation: if record_type == "immunization" and immunization_name is None → 400 Bad Request.

6f. Error Cases

  • POST with invalid record_type → 400

  • POST with record_type = "immunization" but no immunization_name → 400 ("immunization_name required for immunization records")

  • POST with invalid provider_type → 400

  • GET /overdue returns empty array when no records are overdue (not 404)

  • DELETE without supervisor+ → 403

6g. Route Registration, Events, CLI, Web UI, Seed, Tests

Follow the same pattern as Step 5:

  • Routes: /placement/health, /placement/health/{id}, /placement/health/overdue

  • Events: placement.health.created, placement.health.updated

  • CLI: craig health list --child-id, craig health get, craig health create, craig health overdue

  • Web UI: placement/health_list.html page linked from placement detail

  • Seed: 1-3 health records per child (physical_exam, dental, immunization), some with overdue next_due_date

  • Integration tests: 8 total (CRUD, list-by-child, overdue filter, immunization validation, auth, soft-delete)

Step 7: Major Changes Monitoring (45 CFR § 272.15)

Service: craig-security
Federal requirement: 45 CFR § 272.15 equivalent for CCWIS staffing compliance

7a. Database Migration

File: services/craig-security/migrations/20260321000000_major_changes.sql

CREATE TABLE major_changes (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    change_type     TEXT NOT NULL CHECK (change_type IN ('worker_assignment', 'supervisor_change', 'org_restructure', 'system_upgrade', 'policy_change')),
    description     TEXT NOT NULL,
    affected_scope  TEXT NOT NULL,
    effective_date  DATE NOT NULL,
    reported_to_acf BOOLEAN NOT NULL DEFAULT false,
    reported_date   DATE,
    reported_by     TEXT NOT NULL,
    reported_by_name TEXT,
    notes           TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    active          BOOLEAN NOT NULL DEFAULT true,
    deleted_at      TIMESTAMPTZ
);

CREATE INDEX idx_major_changes_type ON major_changes(change_type) WHERE active = true;
CREATE INDEX idx_major_changes_date ON major_changes(effective_date) WHERE active = true;

7b. Store Model

File: services/craig-security/src/store/models.rs — add:

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct MajorChange {
    pub id: Uuid,
    pub change_type: String,
    pub description: String,
    pub affected_scope: String,
    pub effective_date: NaiveDate,
    pub reported_to_acf: bool,
    pub reported_date: Option<NaiveDate>,
    pub reported_by: String,
    pub reported_by_name: Option<String>,
    pub notes: Option<String>,
    pub created_at: DateTime<Utc>,
    pub active: bool,
    pub deleted_at: Option<DateTime<Utc>>,
}

7c. Store Functions

File: services/craig-security/src/store/changes.rs — new module. Follow nist.rs pattern.

  • create_major_change(pool, change_type, description, affected_scope, effective_date, notes, reported_by, reported_by_name) → Result<MajorChange>

  • get_major_change(pool, id) → Result<Option<MajorChange>>

  • list_major_changes(pool, limit, offset, change_type, search, sort_by, sort_dir) → Result<Vec<MajorChange>>

  • count_major_changes(pool, change_type, search) → Result<i64>

  • update_major_change(pool, id, reported_to_acf, reported_date, notes) → Result<Option<MajorChange>>

  • delete_major_change(pool, id) → Result<Option<MajorChange>>

7d. API Request/Response Types

File: services/craig-security/src/api/changes.rs — new module (or add to api.rs).

#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateMajorChangeRequest {
    pub change_type: String,           // validated enum
    pub description: String,
    pub affected_scope: String,
    pub effective_date: NaiveDate,
    pub notes: Option<String>,
}

#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateMajorChangeRequest {
    pub reported_to_acf: Option<bool>,
    pub reported_date: Option<Option<NaiveDate>>,
    pub notes: Option<Option<String>>,
    pub description: Option<String>,
}

#[derive(Deserialize)]
pub struct ListMajorChangesQuery {
    #[serde(default = "default_page")]
    pub page: u32,
    #[serde(default = "default_per_page")]
    pub per_page: u32,
    pub change_type: Option<String>,
    pub search: Option<String>,
    pub sort_by: Option<String>,
    pub sort_dir: Option<String>,
}

7e. Validation

const VALID_CHANGE_TYPES: &[&str] = &["worker_assignment", "supervisor_change", "org_restructure", "system_upgrade", "policy_change"];

7f. Error Cases

  • All endpoints require admin role — caseworker/supervisor → 403

  • POST with invalid change_type → 400

  • POST with empty description or affected_scope → 400 (serde)

  • GET /{id} nonexistent → 404

  • DELETE on already-deleted → 404

7g. Route Registration

File: services/craig-security/src/api.rs (or api/mod.rs) — add to routes():

.route("/security/changes", get(list_major_changes).post(create_major_change))
.route("/security/changes/{id}", get(get_major_change).put(update_major_change).delete(delete_major_change))

7h. Events

File: services/craig-security/src/events.rs — add:

  • security.major_change.recorded — payload: { change_id, change_type, affected_scope }

  • security.major_change.reported — payload: { change_id, reported_date }

7i. CLI

File: services/craig-cli/src/cmd/security.rs — add subcommands:

  • craig security changes list [--change-type TYPE] [--page N]

  • craig security changes get <id>

  • craig security changes create --type TYPE --description DESC --scope SCOPE --date DATE

  • craig security changes update <id> --reported-to-acf --reported-date DATE

  • craig security changes delete <id>

7j. Web UI

File: services/craig-web/templates/security/changes.html — new page. Route: GET /security/changes in services/craig-web/src/routes/security.rs

Data table with columns: Type, Description, Scope, Effective Date, Reported to ACF, Reported Date. Filter tabs by change_type. Search field. Sortable columns. "Record Change" button with form.

Add "Major Changes" button alongside Audit Log, Reviews, Archive, NIST Controls in the security page header.

7k. Seed Data

Generate 3 major change records: one worker_assignment, one org_restructure, one system_upgrade. Two marked reported_to_acf = true.

7l. Integration Tests

File: services/craig-security/tests/api/changes.rs — new test file.

Tests (6 total):

  1. create_major_change — POST returns 200 with all fields

  2. get_major_change — GET /{id} returns record

  3. list_major_changes — GET returns paginated list

  4. update_major_change_mark_reported — PUT with reported_to_acf: true and reported_date

  5. delete_major_change — DELETE soft-deletes

  6. create_requires_admin — POST as caseworker returns 403

Files Touched

File Change

services/craig-placement/migrations/

2 new migration files (education, health)

services/craig-placement/src/store/models.rs

Add EducationRecord, HealthRecord structs

services/craig-placement/src/store/education.rs

New: 6 CRUD functions

services/craig-placement/src/store/health.rs

New: 8 CRUD + overdue functions

services/craig-placement/src/store/mod.rs

Register new modules

services/craig-placement/src/api/education.rs

New: 5 endpoint handlers

services/craig-placement/src/api/health.rs

New: 6 endpoint handlers

services/craig-placement/src/api/mod.rs

Register modules, add routes

services/craig-placement/src/api/validation.rs

Add school_type, record_type, provider_type validators

services/craig-placement/src/events.rs

Add 4 event publishers

services/craig-placement/tests/api/education.rs

New: 7 tests

services/craig-placement/tests/api/health.rs

New: 8 tests

services/craig-placement/tests/api/mod.rs

Register test modules

services/craig-security/migrations/

1 new migration (major_changes)

services/craig-security/src/store/models.rs

Add MajorChange struct

services/craig-security/src/store/changes.rs

New: 6 CRUD functions

services/craig-security/src/store/mod.rs

Register module

services/craig-security/src/api.rs

Add 5 handlers + routes

services/craig-security/src/events.rs

Add 2 event publishers

services/craig-security/tests/api/changes.rs

New: 6 tests

services/craig-cli/src/cmd/education.rs

New: CLI subcommands

services/craig-cli/src/cmd/health.rs

New: CLI subcommands

services/craig-cli/src/cmd/security.rs

Add changes subcommands

services/craig-cli/src/cmd/mod.rs

Register modules

services/craig-cli/src/main.rs

Register commands

services/craig-web/src/routes/placement.rs

Add education + health list handlers

services/craig-web/src/routes/security.rs

Add changes list handler

services/craig-web/templates/placement/education_list.html

New page

services/craig-web/templates/placement/health_list.html

New page

services/craig-web/templates/security/changes.html

New page

tools/craig-seed/src/model.rs

Add seed structs

tools/craig-seed/src/datagen.rs

Add generation functions (RNG-stable)

tools/craig-seed/src/sql.rs

Add SQL renderers

tests/e2e/specs/

New E2E specs for education, health, changes pages

Verification

  1. cargo fmt --all — formatting clean

  2. cargo clippy --workspace — -D warnings — no warnings

  3. cargo nextest run --workspace --lib — unit tests pass

  4. cargo xtask dev reload (schema changes require full reload)

  5. cargo nextest run --workspace — integration tests pass (new + existing)

  6. cargo xtask e2e — run 5x locally, all E2E tests pass

  7. Verify new tables: docker exec craig-postgres-1 psql -U craig craig_placement -c '\dt education_records'

  8. Verify new tables: docker exec craig-postgres-1 psql -U craig craig_placement -c '\dt health_records'

  9. Verify new tables: docker exec craig-postgres-1 psql -U craig craig_security -c '\dt major_changes'

  10. Verify new endpoints respond: curl test with Bearer token

  11. Regenerate screenshots: SCREENSHOTS=1 cargo xtask e2e --project=setup --project=screenshots

Documentation Updates

  • .claude/docs/services.md — add endpoints (5+6+5=16), tables (3), events (6)

  • CHANGELOG.adoc — entries under == Unreleased

  • .claude/CLAUDE.md Phase Status table — update Phase 3/4 endpoint/table/test counts

  • docs/modules/ROOT/pages/roadmap.adoc — tick education, health, major changes items

  • docs/modules/ROOT/pages/data-model-placement.adoc — add education + health ER diagrams

  • docs/modules/ROOT/pages/guide/caseworker.adoc — education and health record management

  • docs/modules/ROOT/pages/guide/admin.adoc — major changes page

  • This plan — update status to Complete, move nav entry to archive

Edit this page · latest