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
-
contactstable withnarrativeTEXT field,contact_type,duration_minutes,occurred_by -
contact_attachmentstable for file uploads viacraig-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_issuestable in craig-reporting:issue_type,field_name,severity,resolvedtracking -
data_quality_metricstable for point-in-time dashboard data -
GET /v1/reporting/quality/dashboardendpoint (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/evaluatewith eligibility context -
Financial service tracks
ive_eligible,ffp_rate,ffp_amounton payments
Step 4: Reunification & Permanency — COMPLETE
-
case_planstable:permanency_goalfield (reunification, adoption, guardianship, APPLA, relative_placement) -
case_plan_tasksfor action items assigned to Parent/Agency/Provider/Other -
case_milestonesauto-generated by Rules Engine:permanency_hearing(365d),case_plan_review(180d),redetermination -
Timeliness rule sets monitor ASFA deadlines
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_namederivation) -
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
-
POSTwith invalidschool_type→ 400 Bad Request ("invalid school_type: xyz") -
POSTwith missingschool_nameorenrollment_date→ 400 (serde deserialization) -
PUTwith invalidwithdrawal_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):
-
create_education_record— POST with valid data returns 200 with all fields -
get_education_record— GET /{id} returns the created record -
list_education_by_child— GET ?child_id= returns records, respects pagination -
update_education_record— PUT with withdrawal_date and reason returns updated record -
delete_education_record— DELETE returns soft-deleted record, subsequent list excludes it -
create_education_invalid_school_type— POST with invalid school_type returns 400 -
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
-
POSTwith invalidrecord_type→ 400 -
POSTwithrecord_type = "immunization"but noimmunization_name→ 400 ("immunization_name required for immunization records") -
POSTwith invalidprovider_type→ 400 -
GET /overduereturns empty array when no records are overdue (not 404) -
DELETEwithout 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.htmlpage 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
adminrole — caseworker/supervisor → 403 -
POSTwith invalidchange_type→ 400 -
POSTwith emptydescriptionoraffected_scope→ 400 (serde) -
GET /{id}nonexistent → 404 -
DELETEon 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):
-
create_major_change— POST returns 200 with all fields -
get_major_change— GET /{id} returns record -
list_major_changes— GET returns paginated list -
update_major_change_mark_reported— PUT withreported_to_acf: trueandreported_date -
delete_major_change— DELETE soft-deletes -
create_requires_admin— POST as caseworker returns 403
Files Touched
| File | Change |
|---|---|
|
2 new migration files (education, health) |
|
Add |
|
New: 6 CRUD functions |
|
New: 8 CRUD + overdue functions |
|
Register new modules |
|
New: 5 endpoint handlers |
|
New: 6 endpoint handlers |
|
Register modules, add routes |
|
Add school_type, record_type, provider_type validators |
|
Add 4 event publishers |
|
New: 7 tests |
|
New: 8 tests |
|
Register test modules |
|
1 new migration (major_changes) |
|
Add |
|
New: 6 CRUD functions |
|
Register module |
|
Add 5 handlers + routes |
|
Add 2 event publishers |
|
New: 6 tests |
|
New: CLI subcommands |
|
New: CLI subcommands |
|
Add changes subcommands |
|
Register modules |
|
Register commands |
|
Add education + health list handlers |
|
Add changes list handler |
|
New page |
|
New page |
|
New page |
|
Add seed structs |
|
Add generation functions (RNG-stable) |
|
Add SQL renderers |
|
New E2E specs for education, health, changes pages |
Verification
-
cargo fmt --all— formatting clean -
cargo clippy --workspace — -D warnings— no warnings -
cargo nextest run --workspace --lib— unit tests pass -
cargo xtask dev reload(schema changes require full reload) -
cargo nextest run --workspace— integration tests pass (new + existing) -
cargo xtask e2e— run 5x locally, all E2E tests pass -
Verify new tables:
docker exec craig-postgres-1 psql -U craig craig_placement -c '\dt education_records' -
Verify new tables:
docker exec craig-postgres-1 psql -U craig craig_placement -c '\dt health_records' -
Verify new tables:
docker exec craig-postgres-1 psql -U craig craig_security -c '\dt major_changes' -
Verify new endpoints respond:
curltest with Bearer token -
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.mdPhase 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