Plan: Standalone Intake Backend Plugin (SHINES Alignment)

On this page
Contents

This plan implements ADR-042. It was hardened through three contextless reviewer passes + a conventions audit + an implementability audit before approval, and incorporates the flat-file key-registry sidecar and the no-partner-tenancy auth model.

Status

MR Description Status

1

(#681) IntakeSink trait seam + ForwardRequest ctx + backend-agnostic TrackingReference

Done (2026-06-23) — !786

2

(#682) BackendProfile + config + ShinesHouseholdRole + SDK wire-contract views

Done (2026-06-23) — !787

3

(#683) RefVals crosswalk (pinned placeholders, craig-reference)

Done (2026-06-23) — !788

4

(#684) server typed views + CpsRequest mapper + boot fidelity gate + ShinesSink + mock

Done (2026-06-23) — !789

5

(#685) field-relevance rule engine (server) + GET /public/v1/intake-schema

Done (2026-06-23) — !790

6

(#686) schema-driven form driver (CSP-safe)

Done (2026-06-23) — !791

7

(#687) SHINES FieldRule rows + ExtraFields parse/sanitize/enforce — server core (3-way split)

Done (2026-06-23) — !792

7a

(#695) SHINES ExtraFields/per-person form capture + devstack SHINES instance + e2e

Done (2026-06-24) — !793

7b

(#696) SDK ExtraFields lockstep (Rust/Python/TS typed submitter)

Done (2026-06-24) — !794

8

(#688) craig-intake-keyring sidecar service (flat-file registry + replay)

Done (2026-06-24) — !795

9

(#689) standalone-SHINES signature-only JWS auth via sidecar + keygen/registration UI

Done (2026-06-24) — !796

10

(#690) e2e + proposed-SHINES-API-spec + sidecar doc + ADR/services/CHANGELOG

Done (2026-06-25) — !797

Epic: &58
Issues: #681–#690 (MR1–MR10)
External-dependency trackers (blocking go-live): #691 (API contract), #692 (RefVals), #693 (required-field set)
ADR: ADR-042
Branch (per MR): feat/intake-shines- / refactor/intake-

Context

CRAIG’s standalone mandatory-reporting portal (craig-intake, a stateless edge per ADR-017) must be deployable in front of SHINES (Georgia’s legacy monolithic CCWIS) as the durable store. While connected, SHINES is the system of record, so the captured report must conform fully to the SHINES CPS_Request schema. That conformance must (1) apply only to standalone mode + a legacy backend, (2) never bind craig-intake’s jurisdiction-neutral core, (3) strip cleanly when CRAIG replaces SHINES, and (4) support add/remove fields plus declared inter-field relationships (conditional visibility / required-if) kept consistent across the human form, the 3rd-party API, and server validation.

Settled while scoping: SHINES has no intake API today (net-new → we define a reasonable contract + a mock); the POST is synchronous and durability-confirming (the returned SHINES record ID is the reporter’s tracking code — the only durability receipt, since the edge is stateless); the RefVals numeric crosswalk is net-new
placeholder
; signing is 3rd-party→CRAIG only (SHINES has none). A design panel (4 approaches / 3 judges) was unanimous: build the seam, not a plugin framework, at one backend — "Approach A, refined with a typed sidecar." The architecture is fixed in ADR-042.

Two scoping decisions shape this plan beyond the ADR:

  • No partner tenancy in standalone-SHINES. There are no 3rd-party "partners." Anyone may submit via the API provided the submission’s detached JWS verifies against a kid present (and active) in a known-key registry. The public form path is unchanged (anonymous, CAPTCHA, IP-rate-limited).

  • A tiny key-registry sidecar holds the only state. craig-intake is stateless (ADR-017) and cannot store the registered public keys or the JWS replay-dedup records. A minimal companion service, craig-intake-keyring, owns that state. Crucially it exposes the same HTTP contracts craig-security and craig-cases already expose for signer-key lookup and replay-check, so intake reuses its existing SignerAuthClient / JwsReplayClient pointed at the sidecar — no new client, and JWS is verified in standalone exactly as in integrated mode. The store is an unencrypted flat JSON file: it holds only public keys (keygen is client-side; private keys never leave the browser) and replay records — all publishable, so encryption would be obscurity, not security (Kerckhoffs).

Scope

In scope: the BackendProfile seam; the ShinesSink; the typed ExtraFields/entry/Narrative views and their SDK lockstep; the RefVals crosswalk (placeholder) + the CRAIG→SHINES CpsRequest mapper + boot fidelity gate; the declared field-relevance rule engine (server + schema endpoint + form driver); the craig-intake-keyring sidecar + the standalone keygen/registration UI + signature-only API auth; the SHINES mock + e2e; the proposed SHINES API spec.

Out of scope (filed as follow-ups): SHINES-outage buffering (the deferred #215 local-outbox — breaks pure statelessness); the generic trait IntakeBackend framework (until a 2nd backend, e.g. SACWIS, is real); entry-scoped (per-child/per-adult) relevance rules; rich cross-field predicate arithmetic; form fidelity beyond SHINES-required fields.

Design

D1. The backend-profile seam

enum BackendProfile { None, Shines } resolved only in boot_standalone (services/craig-intake/src/main.rs:216). All SHINES code lives under services/craig-intake/src/backend/shines/. Stripping SHINES = BackendProfile::Shines → None + delete the directory. The core SubmitReportRequest (crates/craig-intake-contracts/src/report.rs) gains zero new top-level SHINES scalars; integrated/native mode is byte-for-byte unchanged.

D2. IntakeSink trait + backend-agnostic tracking

pub(crate) trait IntakeSink: Send + Sync (#[async_trait], matching AuthzEngine/RulesetSource) in services/craig-intake/src/sink/mod.rs:

#[async_trait]
pub(crate) trait IntakeSink: Send + Sync {
    async fn forward(&self, req: ForwardRequest<'_>) -> Result<AcceptedReport, ApiError>;
    async fn get_report_status(&self, tracking: &TrackingReference) -> Result<ReportStatusResult, ApiError>;
}

ForwardRequest<'a> bundles today’s forward params (partner_context, received_request_id, body: &SubmitReportRequest, description, ip_hash) plus raw: &serde_json::Value — the full submitted body intake already computes for raw_submission forwarding. The trait references NO SHINES type: ShinesSink deserializes its typed ExtraFields/NarrativeView/entry-extensions from raw internally (a // PARTNER-EDGE-UNTYPED boundary inside backend/shines/), so deleting backend/shines/ leaves the generic seam compiling — the "strip cleanly" guarantee holds. CasesForwarderSink uses raw as the forwarded raw_submission value (its only consumer of raw); it parses no backend-specific extra fields. The handlers compute raw once (serde_json::to_value(&body)) and pass it by reference, so cases-mode behavior is byte-identical to the pre-refactor sink (which computed the same value internally). CasesForwarderSink and ShinesSink are the two impls; the cases-specific BOLA/attachment methods
accessors stay on the concrete cases type (verified: no handler mixes a trait method with a BOLA method except partner-status, which can hold both via two `Extension`s).

TrackingReference — a String newtype (SHINES record IDs are legacy NUMBER`s, not UUIDs). Full blast radius (verified — pre-1.0 contract change → `CHANGELOG Changed): the server AcceptedReport (sink/cases_forwarder.rs:23), ReportStatusResult (:30), the inline server ReportConfirmation (api/public.rs:27 — NOT in craig-intake-contracts), CasesForwarderSink::get_report_status(id: Uuid)’s signature, and the status route `Path<Uuid> params (public.rs:123, partner.rs:243). Plus three SDK surfaces in lockstep — Rust (craig-intake-sdk dto.rs:437 ReportConfirmation.id, :449 ReportStatus), Python (sdks/python/…​/types.py — already str), and TypeScript (sdks/typescript/src/types.ts:53). Each SDK’s check_status(…​) client method param also changes from Uuid to the string tracking reference (crates/craig-intake-sdk/src/client.rs:182 + its doc comment, client.ts, client.py). Cases backend wraps TrackingReference(uuid.to_string()).

D3. Typed carriers (no serde_json::Value business logic)

The SHINES report shape is captured in typed views, never the narrative: Value bag:

  • ExtraFields (backend/shines/extra.rs) — top-level non-narrative SHINES-only inputs, idiomatic snake_case CRAIG field names: caretaker block (primary_caretaker + address + relationship, sec_caretaker + address), first_hand_info, is_in_foster_care + foster_care_name/_address, family_name, is_previously_reported, is_any_one_ill_comments, and is_in_military (a captured bool — see appendix A/C; NOT derived from adults[]). Wire shape: ExtraFields travels as additional top-level JSON keys in the SAME request body — the core SubmitReportRequest is unchanged and serde-ignores them; ShinesSink parses ExtraFields from ForwardRequest.raw (D2). For typed submitters, the three SDKs gain an optional ExtraFields they merge into the submission body (the four-encoding lockstep) — moved to MR7 (#687, 2026-06-23 R2): the SDK client-side ExtraFields is consumed by the form/typed submitter, which lands in MR7, so by the plan’s own define-where-consumed principle it belongs there, not in MR4. The MR4 server side parses ExtraFields from raw regardless; end-to-end flow (and the handler preserving the raw request bytes so the keys survive typed deserialization) also arrives in MR7. Sanitization: every new ExtraFields + per-person free-text string runs through the existing sanitize_text pipeline via a new sanitize_extra_fields/extended sanitize_submit_report_in_place pass (validation.rs:469) — the current pass only covers known SubmitReportRequest fields, so without this the new fields bypass HTML/control-char stripping (a real injection gap). Length caps via the same garde/craig_validation constants.

  • Extend the typed NarrativeView (a new typed view over the existing narrative object, + the SDK Narrative struct dto.rs:330 + Python) with 2 net-new fields so all nine question* fields source from the narrative concept (see appendix A) — not ExtraFields.

  • Extend ChildEntry (validation.rs:13) with ssn, is_victim, grade (sex ← existing gender).

  • Extend AdultEntry (validation.rs:45) with date_of_birth, ssn, contact_number, alt_contact_info, rel_to_primary_caretaker, is_other_member, shines_household_role (Mother|Father|Other, the designation in D4), and the address components street/city/state/zip. NOTE (MR4 reconciliation, 2026-06-23): the field names follow the landed SDK Adult wire shape (date_of_birth, not the prose shorthand dob); SHINES models a single Info.address string, so the mapper *composes it from the street/city/state/zip components (the SDK has no single address field) rather than mapping a [adult]address source. Appendix A’s dob/address legend entries are this shorthand.

  • Four-encoding lockstep: every entry/Narrative field added to the server view is added in the same MR to the Rust SDK (crates/craig-intake-sdk/src/types/dto.rs), the Python SDK (sdks/python/src/craig_intake/types.py), AND the TypeScript SDK (sdks/typescript/src/types.ts). This is the #639/#650 drift surface; the existing public-intake integration tests guard it.

D4. Mother/father/otherHousehold designation (the central derivation gap)

SHINES wants discrete singular motherInfo/fatherInfo; CRAIG has a generic adults[] with no parent axis. The designation is captured, not derived: a per-adult shines_household_role: Option<ShinesHouseholdRole> (Mother|Father|Other) on AdultEntry, populated only in standalone-SHINES (form + API). The mapper selects the adult with MothermotherInfo, FatherfatherInfo, all others → otherHouseholdInfo[]. Validation (MR3 fidelity / MR7 rules): at most one Mother and one Father; a missing designated parent yields an omitted SHINES object unless the required-field manifest marks it required (then a typed 422).

D5. RefVals crosswalk — pinned placeholders

backend/shines/crosswalk.rs: a RefValCrosswalk trait + a GaRefVals impl on the craig-reference::afcars::gender_to_afcars pattern (crates/craig-reference/src/afcars.rs:13). Five net-new crosswalks the mapper needs, none of which exist today; placeholder integers are pinned in this plan (appendix B) so the MR3 hash-pinned mapper test is reproducible across implementers, each marked // PLACEHOLDER REFVAL — replace with authoritative SHINES RefVals (#692):

  • raceId (Race → u16), maritalStatusId (MaritalStatus → u16), langId (Language → u16) — placeholder.

  • Gender → "M"/"F" (string, not the afcars u8) — fully specifiable now.

  • bool → "Y"/"N", and Option<bool>"Y"/"N"/"" (None = empty) — fully specifiable now.

  • repType (ReporterType + mandated_reporter_category → SHINES free-text) — placeholder passthrough.

D6. Mapper + fidelity gate

backend/shines/mapper.rs: typed CpsRequest + pure fn map(body, entries, narrative, extra) → Result<CpsRequest, MapError> (thiserror). MapError arms: SsnNotNumeric (digits-only after stripping dashes; the proposed SHINES contract uses a string ssn to avoid the leading-zero loss an integer would cause — flagged for SHINES confirmation, #691), MissingDesignatedParent, UnmappedRefVal. No silent unwrap_or(0). backend/shines/fidelity.rs: a typed required-field manifest (conservative stub from the docx; flagged dependency #693) + a boot_standalone gate that fails closed via anyhow (never panic) when a required field has no source or a crosswalk enum is unmapped.

D7. ShinesSink (synchronous, durability-confirming)

backend/shines/sink.rs: impl IntakeSink for ShinesSink. forward runs the mapper, POSTs CpsRequest synchronously to shines_url, parses the proposed response { "record_id": "<string>", "status": "accepted" }, and returns AcceptedReport { tracking: TrackingReference(record_id), submitted_at }. get_report_status returns a typed Unsupported error (SHINES has no status-read contract in v1), and the status-lookup route is NOT mounted under Shines — the tracking reference is a durable submission receipt the reporter keeps, not a lookup key. This also moots the enumeration concern: SHINES legacy record IDs are not the unguessable UUIDs the cases-mode status endpoint relied on, so exposing a status lookup over them would be an enumeration vector — not offering it is the correct posture. Reuses redact_upstream_error
with_service_identity for the forward POST.

D8. The key-registry sidecar (craig-intake-keyring)

A new minimal workspace member + binary (Alpine/musl/rustls; non-root; HEALTHCHECK), standalone-only, port 8010. Store: an unencrypted JSON flat file (atomic write via temp-file + rename; an in-process parking_lot::RwLock; single instance). It exposes the existing contracts so intake reuses its clients:

  • GET /v1/security/signer-keys/by-kid/{kid} — the shape SignerAuthClient::lookup already calls (signer_auth.rs:88). Must return the full SignerKeyInfo the deserializer expects (signer_auth.rs:34): partner_id: Uuid, partner_status, signer_key_id: Uuid, user_identifier, display_name, public_key_jwk, algorithm. Since there is no partner tenancy, the sidecar emits a fixed synthetic partner_id (the nil UUID) + partner_status: "active" — the EXACT value the verifier accepts (jws.rs:169); "approved" would reject every signed submission.

  • POST /v1/cases/_internal/jws-replay-check — the EXACT path + body JwsReplayClient::check calls (jws_replay.rs:65): { partner_id: Uuid, jti: Uuid, iat: i64 }, semantics 200=Ok, 401=Replay. Because the unchanged client sends only partner_id(=the constant nil synthetic)`jti`iat — kid is NOT in the payload — dedup is keyed by jti (not (kid,jti)); jti is a per-submission random UUID, so cross-holder collision is cryptographically negligible. iat bounds the retention window.

  • POST /keys/register — accepts a browser-generated public JWK + a holder label → stores pending (mirrors craig-security’s signer-key register). Unauthenticated by design (anyone may propose a key; an admin approves before it can sign an accepted report).

  • PUT /keys/{kid}/approve * PUT /keys/{kid}/revoke — admin lifecycle, gated by a bearer admin token (a craig-intake-keyring secret env var; absent ⇒ routes 401). NB: SignerAuthClient caches by-kid hits (Some AND None) for 60s (signer_auth.rs:20,52), so approve/revoke is not observed by intake for up to 60s — tests must account for the TTL (or the keyring exposes a test-only no-cache header).

Reuse the JWK/types from craig-signing / the existing partner_signer_keys DTOs where they fit. Devstack: add a craig-intake-keyring service + a standalone-SHINES intake profile via cargo xtask dev (never docker compose).

D9. Standalone-SHINES JWS auth (no partners)

build_router (main.rs:262) gains a BackendProfile param. Under Shines the partner router is assembled differently — not "drop one middleware." A DISTINCT signed-submit path is required because the existing submit_report_partner cannot be reused: it enforces a partner-id binding verification.partner_id == api_key.id (partner.rs:95) and runs the public-only reporter-type validator (validation.rs:87 via prepare_submission, partner.rs:183). Under Shines:

  • Mount only a new submit_report_signed handler (no api_key_middleware, no Extension<ValidatedApiKey>): it (1) extracts X-JWS-Signature; (2) verifies the detached JWS against the keyring via SignerAuthClient (status "active"); (3) replay-checks via JwsReplayClient (dedup by jti, D8) — no partner-id binding (no tenancy); (4) runs prepare_submission with the FULL reporter-type set; (5) calls ShinesSink.forward.

  • prepare_submission gains an allowed-reporter-types parameter (a ReporterTypeSet/Channel): PublicForm → PUBLIC_REPORTER_TYPES; SignedApi → all ReporterType (so professional/law_enforcement/ self_report — the real SHINES repType submitters — are accepted, not 422’d). The public form path and submit_report_partner keep the public subset.

  • Do NOT mount the cases-bound partner status + attachment routes (partner.rs status, attachments.rs) — they require CasesForwarderSink/cases, absent in Shines.

  • The three SDK clients gain a signature-only submit method (send X-JWS-Signature, omit X-Api-Key; client.rs:63, client.ts:99, client.py:94). OpenAPI: add an X-JWS-Signature security scheme for the signed route (api/mod.rs:29).

  • The keygen + registration UI is copied into intake’s static tree (ui.rs serves via crate-relative include_str! — a cross-service reference to craig-web’s `craig-sign.js/key-registration.js won’t compile) and added file-AND-route. It posts same-origin to an intake proxy route POST /signed/v1/keys/register, which forwards server-side to the sidecar POST /keys/register (#689 R2: the keyring stays internal-only — no CORS, no host-publishing in prod — preserving the #688 deployment boundary; the browser only ever talks to intake’s origin).

D10. Field-relevance rule engine

services/craig-intake/src/relevance/: FieldRule { field: FieldId, relevant_when: Predicate, required_when_relevant: bool }; a typed Predicate over already-captured top-level scalars with a typed operand: enum Predicate { Always, Eq(FieldId, ScalarValue), In(FieldId, Vec<ScalarValue>), IsTrue(FieldId), Present(FieldId), Not(Box<..>), And(Vec<..>), Or(Vec<..>) }, enum ScalarValue { Str(String), Bool(bool) }. Pure evaluate_relevance(rules, body, extra) → RelevanceOutcome (relevant set + missing-required → typed ApiError). The FieldId space spans core SubmitReportRequest scalars and ExtraFields top-level scalars (so appendix C rules referencing is_in_foster_care/is_in_military resolve); under Shines the handler parses + sanitizes ExtraFields from raw (D3) before evaluation, so the same sanitized values feed the rules and the sink. Consumed by prepare_submission (validation.rs:429), the schema endpoint, and the form driver.

Parity rule (exact): today’s report.html:39 x-if="form.reporter_type !== 'anonymous'" gates the four optional reporter-contact fields. Encode as 4 rows: for each of reporter_first_name, reporter_last_name, reporter_phone, reporter_emailrelevant_when: Not(Eq(reporter_type, Str("anonymous"))), required_when_relevant: false (they are Option today — relevance hides them; nothing becomes required, so no regression). The per-entry adult conditionals (is_active_duty → military_branch, validation.rs:345) stay in validate_adult_entry (already DRY server-side); entry-scoped rules are out of scope.

GET /public/v1/intake-schema (public, un-authed, rate-limited; Kerckhoffs-fine — schema is not a secret), utoipa-annotated, returns { backend_profile, fields: [{ field, relevant_when: <Predicate JSON>, required_when_relevant }] } (per-rule key is field, matching FieldRule.field — the wire shape MR5 shipped + hash-pin-locked). Profile-aware (MR7/#687): field_rules(profile) returns the 4 core rows under None and core + the 5 appendix-C SHINES rows under Shines; the response carries backend_profile ("none"/"shines") so the form (MR7a/#695) can gate the SHINES section (no rule ⇒ shown is the MR6 default, so the section is gated on the profile flag, not on rule-absence). The form driver (static/js/intake-schema.js, loaded after config.js, before alpine.min.js) fetches it and toggles x-show/required — strict CSP (script-src 'self') already permits a static fetch driver.

Steps

Each MR: branch off fresh main → implement → full pre-push battery → fresh-eyes J1-J8 over the staged diff → skip-CI merge (PUT /merge after POST) → close issue with impl+merge SHA → prune. cargo xtask dev only.

MR1 (#681): IntakeSink trait + ForwardRequest + TrackingReference

Files: services/craig-intake/src/sink/{mod.rs,cases_forwarder.rs}, main.rs:262, api/{public,partner}.rs (the inline ReportConfirmation + Path<Uuid>→string status params), the three SDKs (crates/craig-intake-sdk/src/types/dto.rs, sdks/python/…​/types.py, sdks/typescript/src/types.ts). Per D2 (full blast radius listed there). Move AcceptedReport/ReportStatusResult to sink/mod.rs; introduce TrackingReference; change get_report_status(id: Uuid)→`(&TrackingReference); install `Extension(Arc<dyn IntakeSink>) on submit/status + keep concrete Extension(CasesForwarderSink) on the partner router. Verify: cargo nextest run -p craig-intake -p craig-intake-sdk green; both modes still forward to cases; the public report→status round-trip still works with the string tracking reference.

MR2 (#682): BackendProfile + config + SDK wire-contract views

Files: backend/mod.rs (BackendProfile), services/craig-intake/src/main.rs (mod backend), config.rs, crates/craig-reference/src/enums.rs (ShinesHouseholdRole), crates/craig-intake-sdk/…​/dto.rs. Per D1+D4 + the SDK side of D3. config.rs: restructure the unconditional cases_url.is_none() guard to a (mode, backend_profile) match — standalone-Shines requires shines_url+keyring_url (not cases_url), Shines+integrated is rejected, every other config requires cases_url; add backend_profile
shines_url + keyring_url; update Debug; rewrite the standalone_requires_cases_url test
add the SHINES-mode cases. SDK (Rust): Child += grade; Adult += shines_household_role (ShinesHouseholdRole), contact_number, alt_contact_info, rel_to_primary_caretaker, is_other_member; Narrative += occur_again, child_danger. (Python/TS SDKs model children/adults/narrative as untyped bags, so the per-person/narrative lockstep is vacuous there — no change.)

resequenced from the original plan (R2, 2026-06-23). The server-side parsing views (ChildEntry/AdultEntry extensions, the server ExtraFields carrier, the server NarrativeView) and the SDK ExtraFields move to MR3 — they are defined where the MR3 mapper first consumes them, so MR2 carries no dead-code window (a server-side typed view defined in MR2 has no MR2 consumer and would trip dead_code). The ShinesHouseholdRole enum lives in craig-reference (shared by the server view + the SDK), not under backend/shines/, because both the intake service and its SDK reference it. Verify: round-trip the new config fields + SDK fields; validate() accepts a Shines config with no cases_url and rejects one with no shines_url/keyring_url; dto-length cap-sync + quality-budgets stay green.

MR3 (#683): RefVals crosswalk (placeholders)

Files: crates/craig-reference/src/shines.rs (new) + lib.rs. Per D5 + appendix B. A set of pub fn crosswalks (race_to_shines_id / marital_status_to_shines_id / language_to_shines_id placeholders; gender_to_shines_code / reporter_type_to_shines_rep_type / bool_to_shines_yn / opt_bool_to_shines_yn stable) modeled on the afcars pattern, each placeholder marked // PLACEHOLDER REFVAL … (#692). Verify: exhaustive variant-iteration tests (total coverage — strictly stronger than a proptest sample for a finite enum): every variant maps to a non-sentinel code; language ids unique; appendix-B placeholder pins.

resequenced from the original plan (R2, 2026-06-23). The crosswalk lives in craig-reference (pub fn`s, public API — never `dead_code, shippable with no consumer yet) rather than under backend/shines/, mirroring the afcars reference tables and the MR2 ShinesHouseholdRole placement. The server typed views (ChildEntry/AdultEntry extensions, server ExtraFields, server NarrativeView) + the CpsRequest mapper + the boot fidelity gate move to MR4 — everything under backend/shines/ is ultimately consumed by ShinesSink, so defining it before MR4 would create a dead_code window that only an [allow]/[expect] could paper over. The SDK/Python/TS ExtraFields (client top-level merge) also moves to MR4, where it pairs with ShinesSink parsing ExtraFields from the wire.

MR4 (#684): server typed views + CpsRequest mapper + fidelity gate + ShinesSink + mock

Files: backend/shines/{mod.rs,extra.rs,narrative.rs,mapper.rs,fidelity.rs,sink.rs}, api/validation.rs (ChildEntry/AdultEntry extensions consumed by the mapper), api/{mod.rs,public.rs} (public_routes(mount_status)), main.rs boot wiring (boot_standalone branches on the backend profile → select ShinesSink + run the fidelity gate under Shines; build_router takes the public IntakeSink + optional partner wiring; do NOT mount the status route — D7), a host-owned SHINES CPS-intake mock in tools/craig-mock-server (shines.rs, merged like the broker — SHINES is a backend, not a registry partner). Per D3 (server side)
D6 + D7 + appendix A. The server ExtraFields/NarrativeView/extended entries + mapper + fidelity are defined here, where the mapper + ShinesSink consume them (the R2 resequencing above — no dead-code window). ShinesSink parses its typed views from ForwardRequest.raw (D2 — no SHINES type on the trait); get_report_status returns Unsupported (503; its route is unmounted). NOTE (2026-06-23): the SDK client-side ExtraFields (top-level merge) + the proposed-SHINES OpenAPI schema are deferred to MR7/MR10 (their consumers — the form/typed submitter and the published spec — land there); MR4 is the server-consumed surface only. Verify: mapper hash-pinned unit test (fixed input → CpsRequest JSON SHA256, NCANDS-TSV precedent); SsnNotNumeric/MissingDesignatedParent sad-paths; fidelity boot-gate sad-path; mock integration via craig_mock_server::spawn_for_test() (submit → mock received the mapped CpsRequest, tracking reference returned + echoed); status route absent under Shines.

MR5 (#685): field-relevance rule engine (server) + schema endpoint

Files: relevance/mod.rs (new), api/validation.rs (prepare_submission enforces the rules post-sanitize), api/public.rs (new GET /intake-schema + IntakeSchema DTO), api/mod.rs (ApiDoc), main.rs (mod relevance). Per D10. Verify: evaluator unit + proptest (a hidden-because-irrelevant field is never required); a wire-shape lock test on the /intake-schema JSON (the form-driver contract); the parity rule reproduces today’s anonymous-hides-contact-fields behavior with no new required field.

NOTE (MR5 reconciliation, 2026-06-23): (1) evaluate_relevance(rules, body) does NOT take the extra (ExtraFields) param D10 sketched — no MR5 rule resolves an ExtraFields field id, so the param + those field ids land in MR7 (687) with the appendix-C rows that use them (define-where-consumed; avoids a dead param). (2) RelevanceOutcome carries missing_required only, not the "relevant set" — the relevant set has no server consumer (the form driver computes visibility client-side from the schema); it can be added in MR7 if a server need appears. (3) The Predicate grammar ships whole (the schema + form-driver wire contract): the In/IsTrue/Present/And/Or/Always arms are unit-tested but gain their first production FieldRule consumers in MR7, carried by a [cfg_attr(not(test), expect(dead_code, …))] that self-clears.

MR6 (#686): schema-driven form driver

Files: static/js/intake-schema.js (new) + ui.rs route, static/report.html (replace the hand-coded x-if). Per D10. Verify: DOM/e2e check that visibility + required track reporter_type from the schema; no regression vs. the hand-coded conditional; CSP unchanged.

MR7 (#687): SHINES rule rows + ExtraFields/per-person capture — server core (3-way split)

R2 (2026-06-24): split 3 ways. As originally scoped MR7 spanned five subsystems (relevance engine, profile-aware schema, server capture/sanitization, the form + a devstack SHINES instance + e2e, and a 3-SDK lockstep) — past one reviewable unit. #687 is now the server core; the form/devstack/e2e moves to #695 (MR7a) and the 3-SDK ExtraFields lockstep to #696 (MR7b). Producer→consumer boundary: #687 makes the server enforce/serve/forward the SHINES rules; #695 makes a form render them (proved over HTTP); #696 gives typed SDK submitters parity.

#687 files: relevance/mod.rs (the appendix-C FieldRule rows in a profile-aware field_rules(profile), IsTrue/Present predicates over new ExtraFields/ICWA FieldId`s + an `extra: &ExtraFields resolve source; require_satisfied now returns 422 to match garde validation), backend/mod.rs + backend/shines/extra.rs (Serialize/ToSchema derives), api/validation.rs (prepare_submission(body, extra, profile)
parse_extra_fields + sanitize_extra_fields + sanitize_forwarded_freetext_in_place over the untyped children/adults/narrative free text — D3; without it those bypass HTML/control-char stripping), api/public.rs (the Bytes read so the public path can parse ExtraFields like the partner path — Shines-gated so a None body’s SHINES-shaped keys are ignored as today; profile-aware intake_schema
IntakeSchema.backend_profile; raw-merge of sanitized core + populated ExtraFields), api/partner.rs
main.rs (profile Extension) + api/mod.rs (OpenAPI). No "badge number" — that field exists in neither schema; the ADR example is illustrative only. Verify (Rust unit + integration, bin-only crate): GET /intake-schema returns core-only under None and core+SHINES under Shines + a backend_profile field; a required-when-relevant SHINES field left blank ⇒ 422 via prepare_submission under Shines; an ExtraFields/per-person/narrative string with embedded HTML is stripped; a None submission carrying SHINES keys stays unaffected (no 4xx, raw core-only).

MR7a (#695): SHINES ExtraFields/per-person form capture + devstack SHINES instance + e2e

Files: static/report.html + static/js/report-form.js (render the ExtraFields + per-person inputs in a section gated on the Shines profile via the backend_profile from /intake-schema; merge ExtraFields as top-level keys + reset), a devstack craig-intake-standalone-shines instance (shines_url→mock SHINES route, keyring_url→placeholder — the public form never contacts the keyring; not dependent on #688)
a Playwright intake-ui-shines project. Verify: e2e — SHINES fields render only under Shines; required-when-relevant blank ⇒ 422 on the form.

MR7b (#696): SDK ExtraFields lockstep (Rust/Python/TS typed submitter)

Files: crates/craig-intake-sdk (an ExtraFields struct merged top-level into the submit body), sdks/python + sdks/typescript (the untyped ExtraFields merge, per the existing per-person/narrative design). Verify: the public-intake drift/integration tests guard SDK↔server ExtraFields parity.

MR8 (#688): craig-intake-keyring sidecar service

Files: new services/craig-intake-keyring/ (Cargo member, main.rs, flat-file store, the 4 routes), Dockerfile, devstack wiring, xtask service registration as needed. Per D8. Verify: register → lookup-by-kid round-trip; replay-check dedups by jti; approve/revoke lifecycle; atomic-write crash-safety test; cargo nextest run -p craig-intake-keyring.

MR9 (#689): standalone-SHINES JWS auth via sidecar + keygen/registration UI

Files: main.rs build_router (BackendProfile param; under Shines mount the new submit_report_signed route, omit api-key middleware + the cases-bound status/attachment routes) boot_standalone (point SignerAuthClient/JwsReplayClient at keyring_url); backend/shines/signed_submit.rs (the distinct handler — JWS-verify, replay-by-jti, NO partner-id binding, full reporter-type set); api/validation.rs (prepare_submission gains the ReporterTypeSet param); the three SDK clients (signature-only submit, no X-Api-Key); api/mod.rs (X-JWS-Signature OpenAPI security scheme); ui.rs + copies of craig-sign.js
key-registration.js into services/craig-intake/static/js/ + a registration page. Per D9. Verify: a browser-generated key registers → admin-approves → a JWS-signed API submission with that active kid (and a professional/law_enforcement reporter type) is accepted and forwarded to SHINES; an unknown/revoked kid, a replayed jti, or an unsigned submission is rejected; the public-form path still uses the public reporter-type subset.

MR10 (#690): e2e + docs

Files: tests/e2e/specs/, docs/modules/ROOT/pages/ (proposed SHINES API spec + sidecar page + intake architecture + services.adoc + the ADR-042 finalize edits), CHANGELOG.adoc. Verify: Playwright (form
signed-API submit against the SHINES mock + sidecar) via cargo xtask e2e --no-refresh; check-docs clean.

Files Touched

Area

Change

services/craig-intake/src/sink/

IntakeSink trait; TrackingReference; ShinesSink

services/craig-intake/src/backend/shines/

NEW: extra, crosswalk, mapper, fidelity, sink, rules

services/craig-intake/src/relevance/

NEW: rule engine + Predicate

services/craig-intake/src/{config,main}.rs

BackendProfile, shines_url/keyring_url, boot wiring

services/craig-intake/src/api/{validation,public,partner}.rs

typed views, schema endpoint, sig-auth

services/craig-intake/static/, ui.rs

schema driver, keygen/registration UI

services/craig-intake-keyring/

NEW sidecar service (flat-file registry + replay)

crates/craig-intake-sdk, sdks/python, sdks/typescript

lockstep DTO/enum additions; TrackingReference

crates/craig-reference/src/

5 SHINES crosswalks (placeholder) + ShinesHouseholdRole

tools/craig-mock-server

SHINES CPS-intake mock route

docs/, CHANGELOG.adoc, nav.adoc

ADR-042, this plan, proposed spec, sidecar page, services index

Verification

Per MR: cargo fmt --all; cargo clippy -p <crate> --all-targets --locked — -D warnings; cargo nextest run -p craig-intake (+ -p craig-intake-sdk for MR2, -p craig-intake-keyring for MR8); mock integration for MR4/MR9; MR6/MR10 Playwright via cargo xtask e2e --no-refresh. Program gates each MR: cargo xtask quality-budgets --fail-on-regression, lints, cargo machete, check-docs, plan-lint. A fresh Explore subagent answers J1-J8 over the staged diff per MR.

External / data dependencies (blocking go-live)

  1. #691 — authoritative SHINES intake API contract (record-id field/type, auth, error shape); we ship a mock + a proposed spec; the proposed contract uses a string ssn and record_id.

  2. #692 — authoritative SHINES RefVals numeric tables (raceId/maritalStatusId/langId/repType); placeholders pinned in appendix B; swap in crosswalk.rs with zero logic change.

  3. #693 — confirm the SHINES required-field set; the docx has no required/optional flags; the manifest in fidelity.rs is a conservative stub.

Documentation Updates

  • ADR-042 finalize edits: the ExtraFields top-level-only scope; the four-encoding SDK lockstep; the sidecar design (replacing the earlier "local JWS verify + local replay store"); the no-partner auth model
    TrackingReference. (Applied in the same MR as this plan.)

  • docs/modules/ROOT/pages/ — proposed SHINES intake API spec; the craig-intake-keyring sidecar page; intake architecture; services.adoc index (intake row + the new sidecar + port 8010).

  • CHANGELOG.adoc == Unreleased — incl. the Changed entry for ReportConfirmationTrackingReference.

  • nav.adoc — ADR-042 + this plan under Active.

Appendix A — CpsRequest <→ CRAIG field-by-field mapping

Source legend: [core] SubmitReportRequest; [child]/[adult] entry view; [narr] NarrativeView; [EF] ExtraFields; [XW] crosswalk; [svr] server-derived; [const] constant.

Wire fidelity: the left-hand CpsRequest keys are the EXACT SHINES JSON keys, emitted verbatim from CPS_Request.json — including the SHINES spellings isInfosterCare (lowercase f) and the questionChidDanger typo. CRAIG-side struct fields are idiomatic snake_case (is_in_foster_care, etc.); the mapper translates both names and values, and pins the exact wire keys in a const table in mapper.rs.

Top-level: incidentDateincident_datetime; countyreporter_county; location←[core] incident_location; reporterRelationshipreporter_relation; primaryCaretaker(+Address+Relationship) ←[EF]; secCaretaker(+Address)←[EF]; firstHandInfo←[EF]; isInfosterCare/fosterCareName/fosterCareAddress ←[EF] ([XW] bool→Y/N); familyName←[EF]; narrativeconcern_description (post-sanitize); dateReported /intakeDate←[svr] submit-time; emergencyInfosafety_concerns; reporterContactNumber←[core] reporter_phone (truncate 12); isInMilitaryis_in_military (captured bool, [XW] →Y/N — NOT derived from adults[]); isInMilitaryParent←[EF]; isPreviouslyReported←[EF]; isNaorit/isNaoritCommentsindian_heritage/indian_heritage_details; isAnyoneIllComments←[EF].

nine question* (all [narr]): questionMalNegOrAbuseabuse_description; questionNegOrAbuseHarmharm_description; questionHowDoYouKnowawareness_source; questionWhenLastOccurmaltreatment_last_occurred; questionMalAccessChildmaltreater_has_access; questionWhereIsChildchildren_location; questionFswsCommentsadditional_comments; net-new in NarrativeView+SDK: questionOccurAgainoccur_again; questionChidDangerchild_danger.

reporterInfo: emailIdreporter_email; firstName/lastNamereporter_first_name/_last_name; telephonereporter_phone; repTypereporter_type(+mandated_reporter_category); middleName/ mobileNumber/rptrTitle/rptrOrgnization*← [EF] (optional; absent today → empty).

motherInfo/fatherInfo (D4: by shines_household_role): firstName/lastName←[adult]; dobdob; ssnssn (string); raceId←[XW]race; maritalStatusId←[XW]marital_status; langId←[XW]language; addressaddress; contactNumbercontact_number; altContactInfoalt_contact_info; isAllegedMaltreator←[XW]is_alleged_maltreater. otherHouseholdInfo[] = remaining adults + relToPrimaryCaretakerrel_to_primary_caretaker, isOtherMember←[XW]is_other_member.

chindInfo[]: firstName/lastName←[child]; dobdate_of_birth; ssnssn (string); isVictim←[XW]is_victim; sex←[XW]gender; raceId←[XW]race; gradegrade.

Appendix B — pinned placeholder RefVals (replace before go-live; #692)

Gender: Male→`"M"`, Female→`"F"` (stable, not placeholder). bool→Y/N: true→`"Y"`, false→`"N"`; Option<bool> None→`""`. Variant names below are the REAL craig-reference::enums variants (verified) — the crosswalk must cover every variant or the MR3 total-coverage proptest fails. Race (placeholder u16, 5 variants): White=1, BlackAfricanAmerican=2, AmericanIndianAlaskaNative=3, Asian=4, NativeHawaiianPacificIslander=5 (NO Unknown/AsianPacificIslander variants exist). MaritalStatus (placeholder, 7 variants): Single=1, Married=2, Divorced=3, Separated=4, Widowed=5, DomesticPartnership=6, Unknown=7. Language (placeholder): assign ints in declaration order to ALL 16 variants (read enums.rs:1257-1290; incl. AmericanSignLanguage, Other) — English=1 …​ Other=16; the MR3 hash-pinned fixture uses English/Spanish only, the proptest enforces all 16 are mapped. repType (placeholder passthrough, 6 ReporterType variants — the signed API can carry the non-public ones too): mandated→ "Mandated Reporter", concerned_citizen→`"Community"`, anonymous→`"Anonymous"`, professional→`"Professional"`, law_enforcement→`"Law Enforcement"`, self_report→`"Self"`.

Appendix C — concrete SHINES FieldRule rows (MR7)

Predicates reference CRAIG snake_case ExtraFields/core fields (top-level scalars the evaluator sees, D10), and use the typed Predicate model — IsTrue(bool)/Present(option), NOT Eq(field,"Y") ("Y"/"N" is only the wire form the mapper emits):

  • foster_care_name, foster_care_address: relevant_when IsTrue(is_in_foster_care), required=true.

  • is_in_military_parent: relevant_when IsTrue(is_in_military), required=true.

  • is_naorit_comments: relevant_when Present(indian_heritage), required=false.

  • sec_caretaker_address: relevant_when Present(sec_caretaker), required=false.

(Per-adult is_active_duty → military_branch remains server-side in validate_adult_entry — entry-scoped, out of the top-level rule engine.)

Edit this page · latest