Plan: Public Child Abuse Reporting System

On this page

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:

  1. A public web form for citizens to report suspected child abuse without authentication

  2. A REST API with client SDK for third-party system integration

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

craig-intake (port 8008)

Database

craig_intake

SDK crate

crates/craig-intake-sdk/

Workspace members added

2 (services/craig-intake, crates/craig-intake-sdk)

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

POST

/public/v1/reports

CAPTCHA

Submit a report

GET

/public/v1/reports/{code}/status

None

Check status (returns only status + timestamps)

Partner (API-key authenticated)

Method Path Auth Description

POST

/partner/v1/reports

X-Api-Key

Submit a report

GET

/partner/v1/reports/{code}/status

X-Api-Key

Check status

Internal (JWT authenticated, caseworker+)

Method Path Description

GET

/v1/intake/reports

List pending reports (paginated, filterable)

GET

/v1/intake/reports/{id}

Full report details

PUT

/v1/intake/reports/{id}/claim

Claim for screening

PUT

/v1/intake/reports/{id}/convert

Convert to referral

PUT

/v1/intake/reports/{id}/screen-out

Screen out with reason

Admin (JWT authenticated, admin only)

Method Path Description

POST

/v1/intake/api-keys

Create API key (returns plaintext once)

GET

/v1/intake/api-keys

List keys

PUT

/v1/intake/api-keys/{id}/revoke

Revoke key

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

governor crate, in-memory token bucket

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 modelsReportSubmission, ReportConfirmation, ReportStatus

  • Builder patternReportBuilder with 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

  1. Caseworker reviews report in craig-web

  2. Clicks "Convert to Referral" — form pre-fills from report data

  3. Craig-web calls PUT /v1/intake/reports/{id}/convert on craig-intake, forwarding the caseworker’s JWT

  4. Craig-intake maps report fields to CreateReferralRequest and calls POST /v1/cases/referrals on craig-cases, forwarding the caseworker’s JWT

  5. Craig-intake updates report status to converted, stores referral_id

  6. Craig-intake publishes intake.report_converted event

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

GET /report

Public report form (standalone template, no nav bar)

POST /report

Submit form → POST to craig-intake public endpoint

GET /report/confirmation/{code}

Success page with confirmation code

GET /report/status

Status check form

GET /report/status/{code}

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

GET /intake/reports

Review queue (new tab in Intake module)

GET /intake/reports/{id}

Report review detail

POST /intake/reports/{id}/claim

Claim report

POST /intake/reports/{id}/convert

Convert to referral

POST /intake/reports/{id}/screen-out

Screen out

Config

New field in WebSettings: intake_url (env: CRAIG_WEB__INTAKE_URL).

Events Published

Routing Key Payload

intake.report_submitted

report_id, admin_unit, concern_type, immediate_danger, source

intake.report_claimed

report_id, claimed_by

intake.report_converted

report_id, referral_id, admin_unit, converted_by

intake.report_screened_out

report_id, screen_out_reason, screened_by

intake.api_key_created

key_id, organization, created_by

intake.api_key_revoked

key_id, revoked_by

craig-security’s wildcard `# subscriber captures all events automatically for audit.

Infrastructure Changes

File Change

Cargo.toml

Add services/craig-intake and crates/craig-intake-sdk to workspace members; add governor + sha2 to workspace deps

devstack/postgres/init.sql

Add CREATE DATABASE craig_intake;

docker-compose.yml

Add craig-intake service (port 8008)

Dockerfile

Add build target + runtime stage for craig-intake

xtask/src/main.rs

Add health check for craig-intake

services/craig-web/src/config.rs

Add intake_url field

crates/craig-test-lib/

Add IntakeClient, update TestConfig, add builders

services/craig-cli/

Add intake command group (list reports, manage API keys)

%APPDATA%\craig\profiles.toml

Add intake_url field

Keycloak realm

No changes needed (existing caseworker role suffices)

New Dependencies

Crate Version Purpose Added to

governor

latest

Token-bucket rate limiting

workspace + craig-intake

sha2

latest

IP + API key hashing

workspace + craig-intake

Implementation Steps

  1. Save plan to repository — write docs/modules/ROOT/pages/plans/public-intake.adoc, add nav entry

  2. Service scaffolding — craig-intake Cargo.toml, main.rs, config, migrations, health check

  3. Public submission — POST /public/v1/reports with confirmation codes, IP hashing, input validation

  4. Status check — GET /public/v1/reports/{code}/status

  5. Rate limiting — governor middleware on public routes

  6. CAPTCHA — configurable verification (disabled in devstack)

  7. Authenticated review — list, detail, claim, convert, screen-out endpoints

  8. Report-to-referral conversion — cross-service call to craig-cases

  9. API key management — create, list, revoke (admin only)

  10. Partner endpoints — API-key-authenticated submission + status

  11. craig-intake-sdk — IntakeClient, ReportBuilder, types, retry, errors

  12. craig-web public pages — report form, confirmation, status check

  13. craig-web review pages — review queue, detail, claim/convert/screen-out

  14. Infrastructure — Dockerfile, docker-compose, devstack scripts, init.sql

  15. craig-test-lib — IntakeClient, builders, test config

  16. CLI — craig intake command group

  17. Testing — full test coverage (see test coverage section)

  18. 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: true sort 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

  1. cargo test --workspace --lib — unit tests pass (transitions, confirmation code generation, validation)

  2. cargo xtask dev restart — devstack healthy with new craig-intake service

  3. cargo test --workspace — integration tests pass (public submission, status check, review workflow, API key lifecycle, SDK client tests)

  4. cargo xtask e2e — E2E tests pass (web form submission, confirmation page, status check, caseworker review flow)

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

reporter_type

✅ Keep

No change

First Name

❌ (single reporter_name)

✅ Required

Split reporter_name into first/last

Last Name

❌ (single reporter_name)

✅ Required

Split reporter_name into first/last

Phone Number

reporter_phone

✅ Keep

No change

Email Address

reporter_email

✅ Keep

No change

Address — Street

✅ Required

New field

Address — City

✅ Required

New field

Address — State

✅ Required

New field — use State enum from craig-reference

Address — Zip

✅ Required

New field

Address — County

✅ Required

New field — use admin_units_for_state() from craig-reference

~~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 (incident_date)

✅ DateTime

Add time component

Relationship to child being reported on

⚠️ Free text (reporter_relation)

✅ 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

incident_location

✅ Keep

No change

Immediate Danger

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 name

Last Name

Yes

Replace unstructured name

Phone Number

Yes

Email Address

No (optional)

Primary Caregiver

Checkbox

Boolean — at least one adult should be marked

Gender

Yes (dropdown)

Use Gender enum from craig-reference

Alleged Maltreater

Checkbox

Boolean

Conditional: Alleged Maltreater Relationship to Victim Child

Dropdown (shown if maltreater = checked)

See Alleged Maltreater Relationship to Victim Child

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

State enum from craig-reference

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 Race enum from craig-reference

Ethnicity

Yes (dropdown)

Use Ethnicity enum from craig-reference

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 name

Last Name

Yes

Replace unstructured name

Is this child a victim of the specific abuse/neglect?

Yes

Boolean — key for linking child to allegation

Relationship to Primary Caregiver

Yes (dropdown)

See Child’s Relationship to Primary Caregiver

Date of Birth (DOB)

Yes

Date field (replaces free-text age)

DOB Approximate

Checkbox

Boolean — "Select checkbox if Unsure/Approximate DOB"

Gender

Yes (dropdown)

Use Gender enum from craig-reference

SSN

No (optional)

⚠️ See Security Considerations: SSN

Language

Yes (dropdown)

See Language

Race

Yes (dropdown)

Use Race enum from craig-reference

Ethnicity

Yes (dropdown)

Use Ethnicity enum from craig-reference

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 concern_description as the primary narrative

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_info

Additional Documentation

File upload

⚠️ See Additional Documentation (File Upload). Guidance displayed: "Do not upload photographs. Share as appropriate with responding investigative team."


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.

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

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

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

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

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

Value Display Label

single

Single

married

Married

divorced

Divorced

separated

Separated

widowed

Widowed

domestic_partnership

Domestic Partnership

unknown

Unknown

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))

Step 6: Review & Submit

  • Read-only summary of all entered data, organized by section

  • Edit buttons per section to jump back

  • Submit button

  • Confirmation code displayed on success


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_KEY environment variable. Plaintext SSN never written to database or logs

  • Access: Only visible to users with admin or supervisor role 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-store crate (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: attachments JSONB array on public_reports table — 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

reporter_first_name / reporter_last_name

Required if reporter_type ≠ "anonymous"

"Reporter name is required for non-anonymous reports"

reporter_street, city, state, zip, county

Required if reporter_type ≠ "anonymous"

"Reporter address is required for non-anonymous reports"

reporter_state

Must be valid State enum value

"Invalid state"

Adult first_name / last_name

Required per adult entry

"Adult name is required"

Adult gender, race, ethnicity

Must be valid enum values

"Invalid {field} value"

Adult military_branch

Required if is_active_duty = true

"Military branch is required for active duty"

Adult maltreater_relationship_to_victim

Required if is_alleged_maltreater = true

"Relationship to victim is required for alleged maltreaters"

Child first_name / last_name

Required per child entry

"Child name is required"

Child relationship_to_caregiver

Required, valid enum value

"Relationship to caregiver is required"

narrative.abuse_description

Required, min 20 characters

"Description of abuse/neglect must be at least 20 characters"

narrative.harm_description

Required

"Description of harm to children is required"

Educational neglect sub-questions

Required if educational_neglect = true

"This field is required when educational neglect is indicated"

CSEC sub-questions

Required if csec_trafficking = "yes"

"This field is required when CSEC/trafficking is indicated"

indian_heritage_details

Required if indian_heritage = "yes"

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

  1. Add new enums to crates/craig-reference/src/enums.rs: MaritalStatus, Language, MilitaryBranch, RelationshipToChild, RelationshipToCaregiver, MaltreaterRelationship, ChildRelationshipToCaregiver — all with strum Display/EnumString/EnumIter + serde + utoipa ToSchema

  2. Add validators to crates/craig-reference/src/validation.rs for each new enum

  3. Create database migration services/craig-intake/migrations/YYYYMMDDHHMMSS_expand_intake_fields.sql

  4. Add ChildEntry, AdultEntry, Narrative structs to services/craig-intake/src/store/models.rs with Serialize/Deserialize

  5. Update PublicReport model: add new columns, change children/adults types

  6. Update store queries in services/craig-intake/src/store/reports.rs: INSERT and SELECT for new columns

  7. Unit tests for new enums and model serialization

Phase 2b: API & SDK Updates

Scope: Update API request/response types, validation, SDK types and builder.

  1. Update SubmitReportRequest in services/craig-intake/src/api/public.rs with new optional fields

  2. Update validation in services/craig-intake/src/api/validation.rs: validate enum values for new dropdown fields, validate typed children/adults arrays

  3. Update ReportSubmission in crates/craig-intake-sdk/src/types.rs with new optional fields

  4. Update ReportBuilder with new builder methods for all new fields

  5. Add file upload endpoint: POST /public/v1/reports/{code}/attachments

  6. Add craig-store dependency to craig-intake for attachment storage

  7. Update SDK unit tests

  8. Run integration tests

Phase 2c: Web Form (Multi-Step Wizard)

Scope: Replace single-page form with 6-step wizard in craig-web.

  1. Create Alpine.js reportWizard() component with step state management, validation per step, and progress indicator

  2. Implement Step 1 (Reporter) — conditional fields for non-anonymous reporters

  3. Implement Step 2 (Incident Details) — conditional fields for self-reporting, Indian heritage, safety concerns

  4. Implement Step 3 (Adults) — dynamic list with conditional fields for maltreater and active duty

  5. Implement Step 4 (Children) — dynamic list with demographics

  6. Implement Step 5 (Narrative) — conditional sub-question cards for educational neglect and CSEC

  7. Implement Step 6 (Review & Submit) — read-only summary with section edit links

  8. Update services/craig-web/src/routes/report.rs to handle new form fields and construct expanded API request

  9. Update report detail view (intake/report_detail.html) to display all new fields

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

  1. Integration tests: submit reports with all new fields, verify storage and retrieval

  2. Integration tests: file upload endpoint (upload, size limit, type restriction)

  3. Integration tests: backwards compatibility — old-format submissions still accepted

  4. E2E tests: walk through all 6 wizard steps, submit, verify confirmation

  5. E2E tests: conditional field visibility (self-reporting, Indian heritage, CSEC, educational neglect)

  6. E2E tests: file upload via form

  7. Update .claude/docs/services.md with new endpoint and field documentation

  8. Update CHANGELOG.adoc

  9. Update Antora implementation guide and intake design spec


Open Questions for Business Unit

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

  2. Dropdown option lists: Are the proposed dropdown values in Dropdown Options complete? Should any be added or removed?

  3. "How often does maltreatment occur?" — Should this be a dropdown (one-time / occasional / ongoing / unknown) or free text?

  4. "When did the maltreatment last occur?" — Should this be a date picker or free text?

  5. "Does the maltreater have access to the child(ren) right now?" — Should this be Yes/No or free text?

  6. Educational neglect school type — Should this be a dropdown (public / private / charter / homeschool / other) or free text?

  7. File upload: Is document upload required for the initial release, or can it be a follow-up enhancement?

  8. 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?

  9. Adult minimum: Must at least one adult be entered per report? Must at least one adult be marked as Primary Caregiver?

  10. Child minimum: Must at least one child be entered per report? (Current form allows zero children.)


GitLab (Phase 2)

  • Epic: New epic — "Intake 3rd Party Reporting Form Expansion"

  • Issues: 4 issues (one per implementation sub-phase: 2a, 2b, 2c, 2d)

  • Labels: feat, craig-intake, craig-web, craig-reference

  • Priority: P1-high (business-driven requirement)

Edit this page · latest