Plan: Public Child Abuse Reporting System
On this page
- Status
- Phase 1: Original Service (COMPLETE)
- Architecture: New
craig-intakeService - Data Model
- API Endpoints
- Router Composition
- Anti-Abuse Measures
- Client SDK:
craig-intake-sdk - Report-to-Referral Conversion Flow
- craig-web Changes
- Events Published
- Infrastructure Changes
- New Dependencies
- Implementation Steps
- Test Coverage
- Security Considerations
- Verification
- Phase 2: 3rd Party Reporting Field Expansion (DRAFT)
- Context
- Gap Analysis: Reporter Information
- Gap Analysis: Incident Details
- Gap Analysis: Adult People (Demographics)
- Gap Analysis: Children Information
- Gap Analysis: Narrative
- Dropdown Options
- Web Form Layout (Multi-Step Wizard)
- Security Considerations: SSN
- Additional Documentation (File Upload)
- Data Model Changes
- API Changes (Phase 2)
- Validation Rules Summary (Phase 2)
- Implementation Phases (Phase 2)
- Open Questions for Business Unit
- GitLab (Phase 2)
Status
Phase 1 (original service, SDK, web form, review workflow) is COMPLETE — implemented and deployed. All content in sections below through "Verification" describes the delivered system.
Phase 2 (3rd Party Reporting Field Expansion) is COMPLETE.
-
Phase 2a (Reference Enums & Data Model) — COMPLETE (MR !26 merged). 7 enums, migration, typed JSONB structs, model expansion, store refactor, SDK/web rename.
-
Phase 2b (API & SDK Updates) — COMPLETE (MR !27 merged). Typed children/adults validation, conditional logic, file upload endpoint, SDK expanded with Phase 2 fields.
-
Phase 2c (Web Form Wizard) — COMPLETE (single-page Alpine.js form with conditional sections)
-
Phase 2d (Testing & Documentation) — COMPLETE — 9 expanded-field integration tests, 7 attachment integration tests, 11 Playwright E2E tests, services.md + CHANGELOG updated
-
Phase 2e (JWS Key Registry + Verification) — IN PROGRESS — MR 1 (signer key registry) complete, MR 2 (JWS verification) complete, MR 3 (key management web page + browser signing library) complete. See JWS & Multi-Language SDKs plan and ADR-010
-
Phase 2f (Multi-Language SDKs: TypeScript, Python) — PENDING — see JWS & Multi-Language SDKs plan
Phase 1: Original Service (COMPLETE)
Context
CRAIG currently has an internal intake/referral system where authenticated caseworkers record reports received by phone. There is no way for the public to submit abuse reports directly, and no API for third-party systems (schools, hospitals, law enforcement) to submit reports programmatically.
This plan adds:
-
A public web form for citizens to report suspected child abuse without authentication
-
A REST API with client SDK for third-party system integration
-
AGPL v3 copyleft virality through the SDK — any system that links against it must open-source under AGPL v3
The SDK is the strategic centerpiece: it provides enough value (typed models, retry logic, builder API, error handling) that integrators will prefer it over raw HTTP, triggering AGPL copyleft. Organizations that want to avoid AGPL can call the raw REST API directly (HTTP calls do not constitute "linking").
Architecture: New craig-intake Service
Decision: Dedicated service, not added to craig-cases.
Rationale:
-
Security isolation — public-facing service is separated from internal case data
-
Independent scaling — public traffic spikes (awareness campaigns, media coverage) don’t affect caseworker APIs
-
Clean auth boundary — every existing CRAIG service has auth on all
/v1/routes; mixing public endpoints breaks that invariant -
Follows CRAIG’s one-service-per-domain pattern
| Component | Details |
|---|---|
Service |
|
Database |
|
SDK crate |
|
Workspace members added |
2 ( |
Data Model
public_reports table
A public report is a "pre-referral" — unscreened input from the public that needs caseworker review before entering the case management workflow.
CREATE TABLE public_reports (
id UUID PRIMARY KEY DEFAULT uuidv7(),
confirmation_code TEXT NOT NULL UNIQUE, -- RPT-YYYYMMDD-XXXX
status TEXT NOT NULL DEFAULT 'pending',
-- Reporter (optional for anonymous)
reporter_type TEXT NOT NULL, -- anonymous, mandated, concerned_citizen
reporter_name TEXT,
reporter_phone TEXT,
reporter_email TEXT,
reporter_relation TEXT,
-- Concern
admin_unit TEXT NOT NULL,
concern_type TEXT NOT NULL, -- physical_abuse, sexual_abuse, neglect, etc.
concern_description TEXT NOT NULL,
incident_location TEXT,
incident_date DATE,
immediate_danger BOOLEAN NOT NULL DEFAULT false,
additional_info TEXT,
-- Children and perpetrators as JSONB (free-text descriptions, not Person records)
children JSONB NOT NULL DEFAULT '[]',
alleged_perpetrators JSONB NOT NULL DEFAULT '[]',
-- Source tracking
source TEXT NOT NULL DEFAULT 'web', -- web, api
source_system_id TEXT, -- third-party tracking ID
ip_hash TEXT, -- SHA-256 of IP
-- Review (populated by caseworker)
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
referral_id UUID, -- links to craig-cases referral after conversion
screen_out_reason TEXT,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Children/perpetrators are stored as JSONB arrays of free-text descriptions (name, age estimate, physical description, relation). Public reporters cannot create Person records — that happens when a caseworker converts the report to a referral.
api_keys table
For third-party API access (schools, hospitals, law enforcement systems).
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT uuidv7(),
key_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the API key
key_prefix TEXT NOT NULL, -- first 8 chars for identification
name TEXT NOT NULL,
organization TEXT NOT NULL,
contact_email TEXT NOT NULL,
rate_limit_rpm INTEGER NOT NULL DEFAULT 60,
active BOOLEAN NOT NULL DEFAULT true,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ
);
State Machine
pending ──→ screening ──→ converted (referral_id populated)
│ │
└────────────┴──→ screened_out (screen_out_reason populated)
-
pending — just submitted, in caseworker review queue
-
screening — caseworker has claimed it
-
converted — screened in, referral created in craig-cases
-
screened_out — rejected with documented reason
API Endpoints
Public (unauthenticated, rate-limited)
| Method | Path | Auth | Description |
|---|---|---|---|
|
|
CAPTCHA |
Submit a report |
|
|
None |
Check status (returns only status + timestamps) |
Partner (API-key authenticated)
| Method | Path | Auth | Description |
|---|---|---|---|
|
|
|
Submit a report |
|
|
|
Check status |
Internal (JWT authenticated, caseworker+)
| Method | Path | Description |
|---|---|---|
|
|
List pending reports (paginated, filterable) |
|
|
Full report details |
|
|
Claim for screening |
|
|
Convert to referral |
|
|
Screen out with reason |
Router Composition
craig-intake cannot use ApiServer::router() directly because it has three auth tiers (public, API-key, JWT).
Instead, it builds its own Axum router and uses ApiServer::serve() for binding:
Router::new()
.nest("/v1/intake", protected_routes.layer(auth_layer))
.nest("/public/v1", public_routes.layer(rate_limit_layer))
.nest("/partner/v1", partner_routes.layer(api_key_layer))
.route("/healthz", get(health_check))
.merge(SwaggerUi::new("/swagger-ui").url(...))
.layer(/* common: body limit, compression, tracing, CORS */)
Anti-Abuse Measures
| Mechanism | Web Form | API (Partner) | Notes |
|---|---|---|---|
CAPTCHA |
Required |
No |
Turnstile/hCaptcha, configurable, disabled in devstack |
Rate limiting |
5/IP/hour |
Per-key RPM |
|
Honeypot field |
Yes |
N/A |
Hidden field, bots fill it, submission silently dropped |
IP hashing |
SHA-256 |
SHA-256 |
Raw IP never stored |
Body limit |
64KB |
64KB |
Smaller than standard 2MB |
Input validation |
Full |
Full |
Min description length, array size limits |
Client SDK: craig-intake-sdk
AGPL v3 Virality Strategy
The SDK is AGPL-3.0-or-later.
When a third-party application adds craig-intake-sdk as a Cargo dependency, the AGPL copyleft triggers: their entire application must be released under AGPL-compatible terms if distributed or run as a network service (Section 13).
This is deliberate — forcing transparency in child welfare systems.
The SDK provides enough value to be the obvious integration choice:
-
Typed models —
ReportSubmission,ReportConfirmation,ReportStatus -
Builder pattern —
ReportBuilderwith fluent API and validation -
Retry logic — exponential backoff with jitter
-
Error types — rich variants mapping to RFC 9457 Problem Details
-
API key management — secure header injection
Organizations that want to avoid AGPL can call the raw REST API directly (HTTP is not "linking").
Public API Surface
pub struct IntakeClient { /* reqwest::Client + base_url + api_key */ }
impl IntakeClient {
pub fn new(base_url: &str, api_key: &str) -> Self;
pub async fn submit_report(&self, report: &ReportSubmission)
-> Result<ReportConfirmation, IntakeError>;
pub async fn check_status(&self, confirmation_code: &str)
-> Result<ReportStatus, IntakeError>;
}
pub struct ReportBuilder { /* fluent builder for ReportSubmission */ }
Dependencies (minimal, standalone)
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
uuid = { version = "1", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
Report-to-Referral Conversion Flow
-
Caseworker reviews report in craig-web
-
Clicks "Convert to Referral" — form pre-fills from report data
-
Craig-web calls
PUT /v1/intake/reports/{id}/converton craig-intake, forwarding the caseworker’s JWT -
Craig-intake maps report fields to
CreateReferralRequestand callsPOST /v1/cases/referralson craig-cases, forwarding the caseworker’s JWT -
Craig-intake updates report status to
converted, storesreferral_id -
Craig-intake publishes
intake.report_convertedevent -
Craig-web redirects caseworker to the new referral in the existing intake UI
craig-web Changes
Public routes (no require_auth, no session required)
| Route | Description |
|---|---|
|
Public report form (standalone template, no nav bar) |
|
Submit form → POST to craig-intake public endpoint |
|
Success page with confirmation code |
|
Status check form |
|
Display report status |
Uses a report_base.html template without ctx.user — separate from the authenticated base.html.
Authenticated routes (existing require_auth middleware)
| Route | Description |
|---|---|
|
Review queue (new tab in Intake module) |
|
Report review detail |
|
Claim report |
|
Convert to referral |
|
Screen out |
Events Published
| Routing Key | Payload |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
craig-security’s wildcard `# subscriber captures all events automatically for audit.
Infrastructure Changes
| File | Change |
|---|---|
|
Add |
|
Add |
|
Add |
|
Add build target + runtime stage for |
|
Add health check for craig-intake |
|
Add |
|
Add |
|
Add |
|
Add |
Keycloak realm |
No changes needed (existing caseworker role suffices) |
New Dependencies
| Crate | Version | Purpose | Added to |
|---|---|---|---|
|
latest |
Token-bucket rate limiting |
workspace + craig-intake |
|
latest |
IP + API key hashing |
workspace + craig-intake |
Implementation Steps
-
Save plan to repository — write
docs/modules/ROOT/pages/plans/public-intake.adoc, add nav entry -
Service scaffolding —
craig-intakeCargo.toml, main.rs, config, migrations, health check -
Public submission —
POST /public/v1/reportswith confirmation codes, IP hashing, input validation -
Status check —
GET /public/v1/reports/{code}/status -
Rate limiting — governor middleware on public routes
-
CAPTCHA — configurable verification (disabled in devstack)
-
Authenticated review — list, detail, claim, convert, screen-out endpoints
-
Report-to-referral conversion — cross-service call to craig-cases
-
API key management — create, list, revoke (admin only)
-
Partner endpoints — API-key-authenticated submission + status
-
craig-intake-sdk— IntakeClient, ReportBuilder, types, retry, errors -
craig-web public pages — report form, confirmation, status check
-
craig-web review pages — review queue, detail, claim/convert/screen-out
-
Infrastructure — Dockerfile, docker-compose, devstack scripts, init.sql
-
craig-test-lib — IntakeClient, builders, test config
-
CLI —
craig intakecommand group -
Testing — full test coverage (see test coverage section)
-
Documentation — OpenAPI/Swagger, update roadmap
Test Coverage
Unit Tests (cargo test --workspace --lib)
craig-intake service:
-
State machine transitions — valid (pending→screening, screening→converted) and invalid (converted→pending, screened_out→screening)
-
Confirmation code generation — format validation (
RPT-YYYYMMDD-XXXX), uniqueness -
Input validation — concern_type, reporter_type, admin_unit, description min length, children/perpetrators array size limits
-
IP hashing — SHA-256 consistency, different IPs produce different hashes
-
API key generation — key format, prefix extraction, hash verification
-
API key validation — active vs. revoked, expired keys rejected
craig-intake-sdk:
-
ReportBuilder — fluent API, required field validation, build produces valid ReportSubmission
-
ReportSubmission serialization — round-trip serde, all fields preserved
-
IntakeError — variant coverage, Display formatting
-
ReporterType / ConcernType enums — serialization, string conversion
Integration Tests (cargo test --workspace)
Public endpoint tests:
-
Submit report with all fields → 200, returns confirmation code
-
Submit anonymous report (no reporter info) → 200
-
Submit report with immediate_danger flag → 200, verify queue ordering
-
Submit report with missing required fields → 400
-
Submit report with invalid concern_type → 400
-
Submit report with invalid admin_unit → 400
-
Submit report with empty concern_description → 400
-
Submit report with oversized children array (>10) → 400
-
Submit report exceeding 64KB body limit → 413
-
Check status with valid confirmation code → 200, status=pending
-
Check status with invalid confirmation code → 404
-
Status response contains only status + timestamps (no PII)
Partner endpoint tests:
-
Submit via valid API key → 200
-
Submit via revoked API key → 401
-
Submit via expired API key → 401
-
Submit via invalid API key → 401
-
Submit without API key header → 401
-
Source field set to "api" for partner submissions
Authenticated endpoint tests:
-
List reports without auth → 401
-
List reports as caseworker → 200, paginated
-
List reports filtered by status → correct filtering
-
List reports filtered by admin_unit → correct filtering
-
List reports sorted by immediate_danger DESC, submitted_at ASC
-
Get report detail → 200, full report content
-
Claim report (pending→screening) → 200
-
Claim already-claimed report → 409 conflict
-
Convert report (screening→converted) → 200, referral_id populated
-
Convert report verifies referral created in craig-cases
-
Screen out report with reason → 200
-
Screen out without reason → 400
-
Invalid transition (converted→pending) → 409
-
Invalid transition (screened_out→screening) → 409
API key management tests:
-
Create API key as admin → 200, returns plaintext key
-
Create API key as caseworker → 403
-
List API keys as admin → 200
-
Revoke API key → 200, subsequent use returns 401
RBAC tests:
-
All internal endpoints return 401 without auth
-
All internal endpoints return 403 for readonly role
-
Caseworker can list/claim/convert/screen-out
-
Only admin can manage API keys
craig-intake-sdk tests:
-
IntakeClient.submit_report() against live devstack → success
-
IntakeClient.check_status() against live devstack → returns status
-
IntakeClient retries on transient failure (mock server)
-
IntakeClient returns IntakeError on 4xx/5xx responses
E2E Tests (cargo xtask e2e)
e2e/specs/public-report.spec.ts:
-
Navigate to /report → form loads without authentication
-
Submit report with all fields → redirects to confirmation page with code
-
Confirmation page displays confirmation code
-
Navigate to /report/status → status check form loads
-
Enter confirmation code → displays "pending" status
-
Submit report with empty required fields → validation errors shown
-
Submit anonymous report → success (reporter fields optional)
-
Submit report with immediate_danger checked → success
e2e/specs/intake-review.spec.ts:
-
Log in as caseworker → navigate to intake reports → queue visible
-
Report submitted via public form appears in queue
-
Immediate danger reports appear at top of queue
-
Click report → detail page shows all submitted info
-
Claim report → status changes to "screening"
-
Convert report to referral → redirects to referral detail
-
Converted referral has data from original report
-
Screen out report → reason required, status changes
-
Log in as readonly user → intake reports accessible but actions disabled
Security Considerations
-
Status endpoint returns minimal data — only status + timestamps, never report content or reporter PII
-
Confirmation codes are unguessable — derived from UUID v7 bytes
-
CAPTCHA for web, API keys for machines — two anti-abuse paths, no overlap
-
Immediate danger flag — reports flagged
immediate_danger: truesort to top of review queue -
64KB body limit — no file uploads in MVP (evidence described in text, requested during investigation)
-
No raw IPs stored — SHA-256 hashed before persistence
Verification
-
cargo test --workspace --lib— unit tests pass (transitions, confirmation code generation, validation) -
cargo xtask dev restart— devstack healthy with new craig-intake service -
cargo test --workspace— integration tests pass (public submission, status check, review workflow, API key lifecycle, SDK client tests) -
cargo xtask e2e— E2E tests pass (web form submission, confirmation page, status check, caseworker review flow) -
Manual: visit
http://localhost:8080/report, submit a report, check status, review in authenticated UI, convert to referral
Phase 2: 3rd Party Reporting Field Expansion (DRAFT)
Context
The business unit has provided a "Proposed 3rd Party Required Fields" specification that significantly expands the data collected during public child abuse/neglect report submission. The current craig-intake form collects minimal reporter information (name, phone, email, relationship) and unstructured JSONB arrays for children and alleged perpetrators (name, age/relation, description only).
The new specification adds structured demographics for reporters, adults in the household, and children, plus a detailed narrative section with conditional sub-questions for educational neglect and commercial sexual exploitation/trafficking (CSEC).
This phase covers all three intake channels: the public web form (craig-web), the partner API (craig-intake), and the SDK (craig-intake-sdk).
Gap Analysis: Reporter Information
| Field | Current | Proposed | Notes |
|---|---|---|---|
Reporter Type (anonymous/mandated/concerned_citizen) |
✅ |
✅ Keep |
No change |
First Name |
❌ (single |
✅ Required |
Split |
Last Name |
❌ (single |
✅ Required |
Split |
Phone Number |
✅ |
✅ Keep |
No change |
Email Address |
✅ |
✅ Keep |
No change |
Address — Street |
❌ |
✅ Required |
New field |
Address — City |
❌ |
✅ Required |
New field |
Address — State |
❌ |
✅ Required |
New field — use |
Address — Zip |
❌ |
✅ Required |
New field |
Address — County |
❌ |
✅ Required |
New field — use |
~~Middle Name~~ |
❌ |
❌ Excluded |
Struck from spec |
~~Phone Type~~ |
❌ |
❌ Excluded |
Struck from spec |
~~Address Type~~ |
❌ |
❌ Excluded |
Struck from spec |
~~Date of Birth~~ |
❌ |
❌ Excluded |
Struck from spec |
~Gender~ |
❌ |
❌ Excluded |
Struck from spec |
~~Marital Status~~ |
❌ |
❌ Excluded |
Struck from spec |
~SSN~ |
❌ |
❌ Excluded |
Struck from spec |
~Language~ |
❌ |
❌ Excluded |
Struck from spec |
~Race~ |
❌ |
❌ Excluded |
Struck from spec |
~Ethnicity~ |
❌ |
❌ Excluded |
Struck from spec |
Gap Analysis: Incident Details
| Field | Current | Proposed | Notes |
|---|---|---|---|
Incident Date & Time |
⚠️ Date only ( |
✅ DateTime |
Add time component |
Relationship to child being reported on |
⚠️ Free text ( |
✅ Dropdown |
Convert to enum. See Relationship to Child (Reporter) |
Are you the Primary Caregiver of the children? |
❌ |
✅ Yes/No |
New boolean field |
Are you a household member in the home? |
❌ |
✅ Yes/No |
New boolean field |
Relationship to Primary Caregiver |
❌ |
✅ Dropdown |
New field. See Relationship to Primary Caregiver (Reporter) |
Are you the Alleged Maltreater / self-reporting? |
❌ |
✅ Yes/No |
New boolean field |
Conditional: Alleged Maltreater Relationship to Victim Child |
❌ |
✅ Dropdown (shown if self-reporting = Yes) |
New conditional field. See Alleged Maltreater Relationship to Victim Child |
Indian heritage question |
❌ |
✅ Yes/No/Unknown |
New tri-state field |
Conditional: Indian heritage details |
❌ |
✅ Text (shown if heritage = Yes) |
"Please provide details regarding family members with Indian Heritage (i.e., who? Tribe?)" |
Safety concerns in home |
❌ |
✅ Yes/No/Unknown/Unsure |
"Has anyone in the home recently been or is currently ill or are there any other safety concerns?" |
Incident Location |
✅ |
✅ Keep |
No change |
Immediate Danger |
✅ |
✅ Keep |
No change |
Gap Analysis: Adult People (Demographics)
Currently stored as alleged_perpetrators JSONB array with only { name, relation, description }. The new spec renames this section to "Adult People" — it encompasses all adults in the household, not just alleged maltreaters. Each adult has structured demographic fields.
| Field | Required? | Notes |
|---|---|---|
First Name |
Yes |
Replace unstructured |
Last Name |
Yes |
Replace unstructured |
Phone Number |
Yes |
|
Email Address |
No (optional) |
|
Primary Caregiver |
Checkbox |
Boolean — at least one adult should be marked |
Gender |
Yes (dropdown) |
Use |
Alleged Maltreater |
Checkbox |
Boolean |
Conditional: Alleged Maltreater Relationship to Victim Child |
Dropdown (shown if maltreater = checked) |
|
Active Duty |
Checkbox |
Boolean |
Conditional: Military Branch |
Dropdown (shown if active duty = checked) |
See Military Branch |
Address — Street |
Yes |
|
Address — City |
Yes |
|
Address — State |
Yes |
|
Address — Zip |
Yes |
|
Address — County |
Yes |
|
Date of Birth (DOB) |
Yes |
Date field |
DOB Approximate |
Checkbox |
Boolean — "Select checkbox if Unsure/Approximate DOB" |
Marital Status |
Yes (dropdown) |
See Marital Status |
SSN |
No (optional) |
⚠️ See Security Considerations: SSN |
Language |
Yes (dropdown) |
See Language |
Race |
Yes (dropdown) |
Use |
Ethnicity |
Yes (dropdown) |
Use |
Gap Analysis: Children Information
Currently stored as children JSONB array with only { name, age, description }. The new spec adds structured demographic fields.
| Field | Required? | Notes |
|---|---|---|
First Name |
Yes |
Replace unstructured |
Last Name |
Yes |
Replace unstructured |
Is this child a victim of the specific abuse/neglect? |
Yes |
Boolean — key for linking child to allegation |
Relationship to Primary Caregiver |
Yes (dropdown) |
|
Date of Birth (DOB) |
Yes |
Date field (replaces free-text |
DOB Approximate |
Checkbox |
Boolean — "Select checkbox if Unsure/Approximate DOB" |
Gender |
Yes (dropdown) |
Use |
SSN |
No (optional) |
⚠️ See Security Considerations: SSN |
Language |
Yes (dropdown) |
See Language |
Race |
Yes (dropdown) |
Use |
Ethnicity |
Yes (dropdown) |
Use |
Gap Analysis: Narrative
The current form has two free-text fields (concern_description and additional_info). The new spec replaces these with structured narrative questions, several with conditional sub-questions.
| Field | Type | Notes |
|---|---|---|
How has the maltreater neglected or abused the victim child(ren)? |
Text (required) |
Replaces |
How has neglect or abuse harmed/affected the child(ren)? |
Text (required) |
New field |
Has the child(ren) suffered educational neglect? |
Yes/No |
Conditional — if Yes, show sub-questions below |
Sub: How many school days has the child(ren) missed? |
Text |
Shown if educational neglect = Yes |
Sub: What type of school is the child(ren) attending? |
Text |
Shown if educational neglect = Yes |
Sub: What has been done to support the family to address this attendance concern? |
Text |
Shown if educational neglect = Yes |
Sub: What actions has the caretaker taken to address the issue? |
Text |
Shown if educational neglect = Yes |
Sub: Has there been previous concerns with truancy with this family? |
Yes/No/Unknown |
Shown if educational neglect = Yes |
Is the child alleged to be commercially sexually exploited or trafficked? |
Yes/No/Unknown |
Conditional — if Yes, show sub-questions below |
Sub: Are the child’s caregivers aware of these concerns? |
Yes/No/Unknown |
Shown if CSEC = Yes |
Sub: Does the child have social media accounts? |
Yes/No |
Shown if CSEC = Yes |
Sub-sub: What are the child’s social media usernames or handles? |
Text |
Shown if social media = Yes |
Sub: Does the child use Venmo, Cash App, Zelle, or other digital payment platforms? |
Yes/No/Unknown |
Shown if CSEC = Yes |
Sub: Does the child have piercings? |
Yes/No |
Shown if CSEC = Yes |
Sub-sub: Please describe the child’s piercings |
Text |
Shown if piercings = Yes |
Sub: Does the child have a nickname(s)? |
Yes/No |
Shown if CSEC = Yes |
Sub-sub: What is/are the child’s nickname(s)? |
Text |
Shown if nicknames = Yes |
How often does maltreatment occur? |
Text |
New field |
When did the maltreatment last occur? |
Text |
New field |
How did you become aware of the maltreatment? |
Text |
New field |
Does the maltreater have access to the child(ren) right now? |
Text |
New field |
Where are children at this time? |
Text |
New field |
Additional Comments/Notes |
Text (open-ended) |
Maps to existing |
Additional Documentation |
File upload |
⚠️ See Additional Documentation (File Upload). Guidance displayed: "Do not upload photographs. Share as appropriate with responding investigative team." |
Dropdown Options
The following dropdown option lists require business unit review and approval. The values shown are initial proposals based on the spec and existing CRAIG reference data. Additional values can be added without database changes.
Relationship to Child (Reporter)
Used for: "What is your relationship to the child you are reporting on?"
| Value | Display Label |
|---|---|
parent |
Parent |
step_parent |
Step-Parent |
grandparent |
Grandparent |
aunt_uncle |
Aunt/Uncle |
sibling |
Sibling |
other_relative |
Other Relative |
foster_parent |
Foster Parent |
teacher |
Teacher |
school_staff |
School Staff |
medical_professional |
Medical Professional |
law_enforcement |
Law Enforcement |
neighbor |
Neighbor |
family_friend |
Family Friend |
coach_mentor |
Coach/Mentor |
childcare_provider |
Childcare Provider |
other |
Other |
Relationship to Primary Caregiver (Reporter)
Used for: "What is your relationship to the Primary Caregiver of the family you are reporting on?"
| Value | Display Label |
|---|---|
spouse_partner |
Spouse/Partner |
parent |
Parent |
sibling |
Sibling |
other_relative |
Other Relative |
friend |
Friend |
neighbor |
Neighbor |
coworker |
Coworker |
professional_contact |
Professional Contact |
other |
Other |
Alleged Maltreater Relationship to Victim Child
Used for: conditional field when reporter or adult is flagged as alleged maltreater.
| Value | Display Label |
|---|---|
biological_parent |
Biological Parent |
step_parent |
Step-Parent |
adoptive_parent |
Adoptive Parent |
grandparent |
Grandparent |
other_relative |
Other Relative |
foster_parent |
Foster Parent |
household_member |
Household Member (non-relative) |
caregiver |
Caregiver/Babysitter |
other |
Other |
Child’s Relationship to Primary Caregiver
Used for: Children section — "What is the child’s relationship to Primary Caregiver?"
| Value | Display Label |
|---|---|
biological_child |
Biological Child |
step_child |
Step-Child |
adopted_child |
Adopted Child |
grandchild |
Grandchild |
foster_child |
Foster Child |
niece_nephew |
Niece/Nephew |
sibling |
Sibling |
other_relative |
Other Relative |
non_relative |
Non-Relative |
Military Branch
Used for: conditional field when adult is flagged as Active Duty.
| Value | Display Label |
|---|---|
army |
Army |
navy |
Navy |
air_force |
Air Force |
marines |
Marines |
coast_guard |
Coast Guard |
space_force |
Space Force |
national_guard |
National Guard |
reserves |
Reserves |
Marital Status
| Value | Display Label |
|---|---|
single |
Single |
married |
Married |
divorced |
Divorced |
separated |
Separated |
widowed |
Widowed |
domestic_partnership |
Domestic Partnership |
unknown |
Unknown |
Language
| Value | Display Label |
|---|---|
english |
English |
spanish |
Spanish |
vietnamese |
Vietnamese |
korean |
Korean |
mandarin |
Mandarin |
cantonese |
Cantonese |
tagalog |
Tagalog |
arabic |
Arabic |
french |
French |
haitian_creole |
Haitian Creole |
portuguese |
Portuguese |
russian |
Russian |
somali |
Somali |
swahili |
Swahili |
american_sign_language |
American Sign Language (ASL) |
other |
Other |
Existing Enums (No Changes Needed)
The following dropdowns already exist in craig-reference and will be reused:
-
Gender: male, female, non_binary, other, unknown
-
Race: white, black, american_indian_alaska_native, asian, native_hawaiian_pacific_islander, other, unknown, declined
-
Ethnicity: hispanic_latino, not_hispanic_latino, unknown, declined
Web Form Layout (Multi-Step Wizard)
The expanded form has too many fields for a single page. The web form will use a multi-step wizard with progress indicator. Each step validates before allowing navigation to the next.
Step 1: Reporter Information
-
Reporter Type (anonymous / mandated / concerned citizen) — radio buttons
-
If not anonymous:
-
First Name, Last Name (side by side)
-
Phone Number, Email Address (side by side)
-
Address: Street, City (side by side), State + Zip (side by side), County
-
Step 2: Details of Incident
-
County / Admin Unit where incident occurred (existing field)
-
Type of Concern — dropdown (existing)
-
Incident Date & Time — datetime-local input
-
Incident Location — text (existing)
-
Immediate Danger — checkbox (existing)
-
Relationship to child — dropdown
-
Are you the Primary Caregiver? — Yes/No radio
-
Are you a household member? — Yes/No radio
-
Relationship to Primary Caregiver — dropdown
-
Are you the Alleged Maltreater / self-reporting? — Yes/No radio
-
If Yes: Maltreater Relationship to Victim Child — dropdown
-
-
Does the child/parent/caregiver have American Indian heritage? — Yes/No/Unknown radio
-
If Yes: Heritage details — text area
-
-
Are there safety concerns in the household? — Yes/No/Unknown/Unsure radio
Step 3: Adult People in the Household
-
Dynamic list (add/remove, max 10)
-
Per adult:
-
First Name, Last Name (side by side)
-
Phone Number, Email (side by side)
-
Primary Caregiver — checkbox
-
Alleged Maltreater — checkbox
-
If checked: Relationship to Victim Child — dropdown
-
-
Active Duty — checkbox
-
If checked: Military Branch — dropdown
-
-
Address: Street, City, State + Zip, County
-
Date of Birth + "Approximate DOB" checkbox
-
Gender, Marital Status (side by side, dropdowns)
-
SSN — optional, password-masked input
-
Language, Race, Ethnicity (side by side, dropdowns)
-
Step 4: Children Involved
-
Dynamic list (add/remove, max 10)
-
Per child:
-
First Name, Last Name (side by side)
-
Is this child a victim? — checkbox
-
Relationship to Primary Caregiver — dropdown
-
Date of Birth + "Approximate DOB" checkbox
-
Gender — dropdown
-
SSN — optional, password-masked input
-
Language, Race, Ethnicity (side by side, dropdowns)
-
Step 5: Narrative
-
How has the maltreater neglected or abused the victim child(ren)? — textarea (required, min 20 chars)
-
How has neglect or abuse harmed/affected the child(ren)? — textarea (required)
-
Has the child(ren) suffered educational neglect? — Yes/No radio
-
If Yes: sub-question card with 5 fields (see Narrative table above)
-
-
Is the child alleged to be commercially sexually exploited or trafficked? — Yes/No/Unknown radio
-
If Yes: sub-question card with 5 conditional fields (see Narrative table above)
-
-
How often does maltreatment occur? — text
-
When did the maltreatment last occur? — text
-
How did you become aware of the maltreatment? — textarea
-
Does the maltreater have access to the child(ren) right now? — text
-
Where are the children at this time? — text
-
Additional Comments/Notes — textarea
-
Additional Documentation — file upload (see Additional Documentation (File Upload))
Security Considerations: SSN
Social Security Numbers are collected as optional fields for adults and children. SSN handling requires special care:
-
Display: Password-masked input field (
type="password"or masked with last 4 visible) -
Transmission: HTTPS only (enforced by deployment, not application code)
-
Storage: Stored as encrypted text in JSONB using application-level encryption (AES-256-GCM). The encryption key is loaded from
CRAIG_INTAKE__SSN_ENCRYPTION_KEYenvironment variable. Plaintext SSN never written to database or logs -
Access: Only visible to users with
adminorsupervisorrole when viewing report detail. Caseworkers see masked value (last 4 digits only) -
Logging: SSN values excluded from all structured logging and audit events
-
Retention: SSN is passed through to the referral during conversion; intake report SSN is purged (set to null) 90 days after report submission regardless of status
Question for business unit: Is SSN collection strictly necessary at the intake/reporting stage? Most jurisdictions collect SSN during the investigation phase (craig-cases) after identity verification. Collecting SSN on a public web form increases the attack surface. Recommendation: defer SSN collection to post-intake investigation.
Additional Documentation (File Upload)
The spec calls for document upload with guidance: "Do not upload photographs. Share as appropriate with responding investigative team."
-
Storage: Use existing
craig-storecrate (S3-compatible via Garage in devstack) -
Allowed types: PDF, Word documents (.doc, .docx), text files — no image files
-
Max size: 10 MB per file, 3 files maximum per report
-
Guidance text: Displayed prominently above the upload field
-
Storage path:
intake-reports/{report_id}/{filename} -
New DB column:
attachmentsJSONB array onpublic_reportstable — stores[{ "filename": "…", "content_type": "…", "size_bytes": N, "storage_path": "…" }] -
Cleanup: Attachments follow the same retention policy as the report
Data Model Changes
Database Migration: YYYYMMDDHHMMSS_expand_intake_fields.sql
-- Reporter: split name into first/last, add address fields
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_first_name TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_last_name TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_street TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_city TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_state TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_zip TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_county TEXT;
-- Incident details: add structured fields
ALTER TABLE public_reports ALTER COLUMN incident_date TYPE TIMESTAMPTZ
USING incident_date::TIMESTAMPTZ;
ALTER TABLE public_reports RENAME COLUMN incident_date TO incident_datetime;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_is_primary_caregiver BOOLEAN;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_is_household_member BOOLEAN;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_relationship_to_caregiver TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_is_self_reporting BOOLEAN;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS reporter_maltreater_relationship TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS indian_heritage TEXT; -- yes/no/unknown
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS indian_heritage_details TEXT;
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS safety_concerns TEXT; -- yes/no/unknown/unsure
-- Narrative: structured JSONB replacing simple text fields
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS narrative JSONB DEFAULT '{}';
-- Attachments
ALTER TABLE public_reports ADD COLUMN IF NOT EXISTS attachments JSONB DEFAULT '[]';
-- Rename alleged_perpetrators to adults (broader scope)
ALTER TABLE public_reports RENAME COLUMN alleged_perpetrators TO adults;
-- Keep reporter_name for backwards compatibility (existing API consumers).
-- New submissions populate first/last; legacy submissions use reporter_name.
-- reporter_relation becomes an enum value stored in new dropdown field;
-- keep old column for backwards compatibility.
reporter_name and reporter_relation columns are not dropped — they remain for backwards compatibility with existing partner API consumers. New submissions populate the structured fields; the BFF and review UI prefer structured fields with fallback to legacy fields.
|
Typed JSONB Structs
The children and adults JSONB arrays will have typed Rust structs for validation and serialization, replacing the current untyped serde_json::Value.
ChildEntry struct:
pub struct ChildEntry {
pub first_name: String,
pub last_name: String,
pub is_victim: bool,
pub relationship_to_caregiver: String, // enum value
pub date_of_birth: Option<String>, // ISO date string
pub dob_approximate: bool,
pub gender: Option<String>, // Gender enum value
pub ssn: Option<String>, // encrypted at rest
pub language: Option<String>, // Language enum value
pub race: Option<String>, // Race enum value
pub ethnicity: Option<String>, // Ethnicity enum value
}
AdultEntry struct:
pub struct AdultEntry {
pub first_name: String,
pub last_name: String,
pub phone: Option<String>,
pub email: Option<String>,
pub is_primary_caregiver: bool,
pub gender: Option<String>,
pub is_alleged_maltreater: bool,
pub maltreater_relationship_to_victim: Option<String>, // conditional
pub is_active_duty: bool,
pub military_branch: Option<String>, // conditional
pub street: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub zip: Option<String>,
pub county: Option<String>,
pub date_of_birth: Option<String>,
pub dob_approximate: bool,
pub marital_status: Option<String>,
pub ssn: Option<String>, // encrypted at rest
pub language: Option<String>,
pub race: Option<String>,
pub ethnicity: Option<String>,
}
Narrative struct:
pub struct Narrative {
pub abuse_description: String, // required, min 20 chars
pub harm_description: String, // required
// Educational neglect
pub educational_neglect: Option<bool>,
pub school_days_missed: Option<String>,
pub school_type: Option<String>,
pub school_support_provided: Option<String>,
pub caretaker_actions: Option<String>,
pub previous_truancy: Option<String>, // yes/no/unknown
// CSEC / trafficking
pub csec_trafficking: Option<String>, // yes/no/unknown
pub csec_caregiver_aware: Option<String>, // yes/no/unknown
pub csec_social_media: Option<bool>,
pub csec_social_media_handles: Option<String>,
pub csec_digital_payments: Option<String>, // yes/no/unknown
pub csec_piercings: Option<bool>,
pub csec_piercings_description: Option<String>,
pub csec_nicknames: Option<bool>,
pub csec_nicknames_text: Option<String>,
// General
pub maltreatment_frequency: Option<String>,
pub maltreatment_last_occurred: Option<String>,
pub awareness_source: Option<String>,
pub maltreater_has_access: Option<String>,
pub children_location: Option<String>,
pub additional_comments: Option<String>,
}
API Changes (Phase 2)
Backwards Compatibility
The existing v1 API contract is preserved. All new fields are optional in the API request. Existing partner API consumers continue to work without modification — their submissions populate the legacy fields (reporter_name, reporter_relation, untyped children/alleged_perpetrators arrays).
New submissions from the expanded web form and updated SDK send the structured fields. The API accepts both formats:
-
Legacy format:
reporter_name: "Jane Smith",children: [{"name": "…", "age": "…"}] -
New format:
reporter_first_name: "Jane",reporter_last_name: "Smith",children: [{"first_name": "…", "last_name": "…", …}]
The review UI and referral conversion logic prefer new fields with fallback to legacy.
New Request Fields (all optional, added to SubmitReportRequest)
// Reporter (structured, replaces reporter_name)
reporter_first_name: Option<String>
reporter_last_name: Option<String>
reporter_street: Option<String>
reporter_city: Option<String>
reporter_state: Option<String>
reporter_zip: Option<String>
reporter_county: Option<String>
// Incident context
incident_datetime: Option<DateTime> // replaces incident_date
reporter_is_primary_caregiver: Option<bool>
reporter_is_household_member: Option<bool>
reporter_relationship_to_caregiver: Option<String>
reporter_is_self_reporting: Option<bool>
reporter_maltreater_relationship: Option<String>
indian_heritage: Option<String> // "yes" / "no" / "unknown"
indian_heritage_details: Option<String>
safety_concerns: Option<String> // "yes" / "no" / "unknown" / "unsure"
// Narrative (structured, replaces concern_description + additional_info)
narrative: Option<Narrative>
// Attachments (metadata only — files uploaded separately)
// File upload uses multipart POST to /public/v1/reports/{id}/attachments
New Endpoint: File Upload
POST /public/v1/reports/{confirmation_code}/attachments
Content-Type: multipart/form-data
Response: { "filename": "...", "size_bytes": N }
Constraints:
- Max 10 MB per file
- Max 3 files per report
- Allowed types: application/pdf, application/msword,
application/vnd.openxmlformats-officedocument.wordprocessingml.document,
text/plain
- Report must exist and be in "pending" status
- Rate limited (same as report submission)
Validation Rules Summary (Phase 2)
| Field | Rule | Error Message |
|---|---|---|
|
Required if reporter_type ≠ "anonymous" |
"Reporter name is required for non-anonymous reports" |
|
Required if reporter_type ≠ "anonymous" |
"Reporter address is required for non-anonymous reports" |
|
Must be valid State enum value |
"Invalid state" |
Adult |
Required per adult entry |
"Adult name is required" |
Adult |
Must be valid enum values |
"Invalid {field} value" |
Adult |
Required if |
"Military branch is required for active duty" |
Adult |
Required if |
"Relationship to victim is required for alleged maltreaters" |
Child |
Required per child entry |
"Child name is required" |
Child |
Required, valid enum value |
"Relationship to caregiver is required" |
|
Required, min 20 characters |
"Description of abuse/neglect must be at least 20 characters" |
|
Required |
"Description of harm to children is required" |
Educational neglect sub-questions |
Required if |
"This field is required when educational neglect is indicated" |
CSEC sub-questions |
Required if |
"This field is required when CSEC/trafficking is indicated" |
|
Required if |
"Heritage details are required when Indian heritage is indicated" |
File upload content type |
PDF, DOC, DOCX, TXT only |
"File type not allowed. Please upload PDF, Word, or text documents only." |
File upload size |
Max 10 MB |
"File exceeds maximum size of 10 MB" |
File upload count |
Max 3 per report |
"Maximum of 3 documents per report" |
Implementation Phases (Phase 2)
Phase 2a: Reference Enums & Data Model
Scope: Database migration, new enums in craig-reference, typed JSONB structs in craig-intake.
-
Add new enums to
crates/craig-reference/src/enums.rs:MaritalStatus,Language,MilitaryBranch,RelationshipToChild,RelationshipToCaregiver,MaltreaterRelationship,ChildRelationshipToCaregiver— all with strumDisplay/EnumString/EnumIter+ serde + utoipaToSchema -
Add validators to
crates/craig-reference/src/validation.rsfor each new enum -
Create database migration
services/craig-intake/migrations/YYYYMMDDHHMMSS_expand_intake_fields.sql -
Add
ChildEntry,AdultEntry,Narrativestructs toservices/craig-intake/src/store/models.rswithSerialize/Deserialize -
Update
PublicReportmodel: add new columns, changechildren/adultstypes -
Update store queries in
services/craig-intake/src/store/reports.rs: INSERT and SELECT for new columns -
Unit tests for new enums and model serialization
Phase 2b: API & SDK Updates
Scope: Update API request/response types, validation, SDK types and builder.
-
Update
SubmitReportRequestinservices/craig-intake/src/api/public.rswith new optional fields -
Update validation in
services/craig-intake/src/api/validation.rs: validate enum values for new dropdown fields, validate typed children/adults arrays -
Update
ReportSubmissionincrates/craig-intake-sdk/src/types.rswith new optional fields -
Update
ReportBuilderwith new builder methods for all new fields -
Add file upload endpoint:
POST /public/v1/reports/{code}/attachments -
Add
craig-storedependency tocraig-intakefor attachment storage -
Update SDK unit tests
-
Run integration tests
Phase 2c: Web Form (Multi-Step Wizard)
Scope: Replace single-page form with 6-step wizard in craig-web.
-
Create Alpine.js
reportWizard()component with step state management, validation per step, and progress indicator -
Implement Step 1 (Reporter) — conditional fields for non-anonymous reporters
-
Implement Step 2 (Incident Details) — conditional fields for self-reporting, Indian heritage, safety concerns
-
Implement Step 3 (Adults) — dynamic list with conditional fields for maltreater and active duty
-
Implement Step 4 (Children) — dynamic list with demographics
-
Implement Step 5 (Narrative) — conditional sub-question cards for educational neglect and CSEC
-
Implement Step 6 (Review & Submit) — read-only summary with section edit links
-
Update
services/craig-web/src/routes/report.rsto handle new form fields and construct expanded API request -
Update report detail view (
intake/report_detail.html) to display all new fields -
WCAG accessibility: proper focus management on step transitions, ARIA live regions for validation errors
Phase 2d: Testing & Documentation
Scope: Full test coverage, docs update, E2E tests.
-
Integration tests: submit reports with all new fields, verify storage and retrieval
-
Integration tests: file upload endpoint (upload, size limit, type restriction)
-
Integration tests: backwards compatibility — old-format submissions still accepted
-
E2E tests: walk through all 6 wizard steps, submit, verify confirmation
-
E2E tests: conditional field visibility (self-reporting, Indian heritage, CSEC, educational neglect)
-
E2E tests: file upload via form
-
Update
.claude/docs/services.mdwith new endpoint and field documentation -
Update
CHANGELOG.adoc -
Update Antora implementation guide and intake design spec
Open Questions for Business Unit
-
SSN at intake stage: Is SSN collection required on the public reporting form, or can it be deferred to the investigation phase? Collecting SSN on a public web form increases security risk. See Security Considerations: SSN. Recommendation: defer SSN collection to post-intake investigation.
-
Dropdown option lists: Are the proposed dropdown values in Dropdown Options complete? Should any be added or removed?
-
"How often does maltreatment occur?" — Should this be a dropdown (one-time / occasional / ongoing / unknown) or free text?
-
"When did the maltreatment last occur?" — Should this be a date picker or free text?
-
"Does the maltreater have access to the child(ren) right now?" — Should this be Yes/No or free text?
-
Educational neglect school type — Should this be a dropdown (public / private / charter / homeschool / other) or free text?
-
File upload: Is document upload required for the initial release, or can it be a follow-up enhancement?
-
Anonymous reporters: The spec shows reporter demographics as required, but the current form allows anonymous reporting. Should anonymous reporters skip all reporter fields (current behavior), or should anonymous reporting be removed for 3rd party reports?
-
Adult minimum: Must at least one adult be entered per report? Must at least one adult be marked as Primary Caregiver?
-
Child minimum: Must at least one child be entered per report? (Current form allows zero children.)