Plan: Standalone Intake Backend Plugin (SHINES Alignment)
On this page
- Status
- Context
- Scope
- Design
- D1. The backend-profile seam
- D2.
IntakeSinktrait + backend-agnostic tracking - D3. Typed carriers (no
serde_json::Valuebusiness logic) - D4. Mother/father/otherHousehold designation (the central derivation gap)
- D5. RefVals crosswalk — pinned placeholders
- D6. Mapper + fidelity gate
- D7.
ShinesSink(synchronous, durability-confirming) - D8. The key-registry sidecar (
craig-intake-keyring) - D9. Standalone-SHINES JWS auth (no partners)
- D10. Field-relevance rule engine
- Steps
- MR1 (#681):
IntakeSinktrait +ForwardRequest+TrackingReference - MR2 (#682):
BackendProfile+ config + SDK wire-contract views - MR3 (#683): RefVals crosswalk (placeholders)
- MR4 (#684): server typed views +
CpsRequestmapper + fidelity gate +ShinesSink+ mock - MR5 (#685): field-relevance rule engine (server) + schema endpoint
- MR6 (#686): schema-driven form driver
- MR7 (#687): SHINES rule rows +
ExtraFields/per-person capture — server core (3-way split) - MR7a (#695): SHINES
ExtraFields/per-person form capture + devstack SHINES instance + e2e - MR7b (#696): SDK
ExtraFieldslockstep (Rust/Python/TS typed submitter) - MR8 (#688):
craig-intake-keyringsidecar service - MR9 (#689): standalone-SHINES JWS auth via sidecar + keygen/registration UI
- MR10 (#690): e2e + docs
- MR1 (#681):
- Files Touched
- Verification
- External / data dependencies (blocking go-live)
- Documentation Updates
- Appendix A —
CpsRequest<→ CRAIG field-by-field mapping - Appendix B — pinned placeholder RefVals (replace before go-live; #692)
- Appendix C — concrete SHINES
FieldRulerows (MR7)
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) |
Done (2026-06-23) — !786 |
2 |
(#682) |
Done (2026-06-23) — !787 |
3 |
(#683) RefVals crosswalk (pinned placeholders, craig-reference) |
Done (2026-06-23) — !788 |
4 |
(#684) server typed views + |
Done (2026-06-23) — !789 |
5 |
(#685) field-relevance rule engine (server) + |
Done (2026-06-23) — !790 |
6 |
(#686) schema-driven form driver (CSP-safe) |
Done (2026-06-23) — !791 |
7 |
(#687) SHINES |
Done (2026-06-23) — !792 |
7a |
(#695) SHINES |
Done (2026-06-24) — !793 |
7b |
(#696) SDK |
Done (2026-06-24) — !794 |
8 |
(#688) |
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 |
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
kidpresent (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 existingSignerAuthClient/JwsReplayClientpointed 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, andis_in_military(a captured bool — see appendix A/C; NOT derived fromadults[]). Wire shape:ExtraFieldstravels as additional top-level JSON keys in the SAME request body — the coreSubmitReportRequestis unchanged and serde-ignores them;ShinesSinkparsesExtraFieldsfromForwardRequest.raw(D2). For typed submitters, the three SDKs gain an optionalExtraFieldsthey merge into the submission body (the four-encoding lockstep) — moved to MR7 (#687, 2026-06-23 R2): the SDK client-sideExtraFieldsis 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 parsesExtraFieldsfromrawregardless; end-to-end flow (and the handler preserving the raw request bytes so the keys survive typed deserialization) also arrives in MR7. Sanitization: every newExtraFields+ per-person free-text string runs through the existingsanitize_textpipeline via a newsanitize_extra_fields/extendedsanitize_submit_report_in_placepass (validation.rs:469) — the current pass only covers knownSubmitReportRequestfields, so without this the new fields bypass HTML/control-char stripping (a real injection gap). Length caps via the samegarde/craig_validationconstants. -
Extend the typed
NarrativeView(a new typed view over the existingnarrativeobject, + the SDKNarrativestructdto.rs:330+ Python) with 2 net-new fields so all ninequestion*fields source from the narrative concept (see appendix A) — notExtraFields. -
Extend
ChildEntry(validation.rs:13) withssn,is_victim,grade(sex← existinggender). -
Extend
AdultEntry(validation.rs:45) withdate_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 componentsstreet/city/state/zip. NOTE (MR4 reconciliation, 2026-06-23): the field names follow the landed SDKAdultwire shape (date_of_birth, not the prose shorthanddob); SHINES models a singleInfo.addressstring, so the mapper *composes it from thestreet/city/state/zipcomponents (the SDK has no singleaddressfield) rather than mapping a[adult]addresssource. Appendix A’sdob/addresslegend 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 Mother → motherInfo, Father → fatherInfo, 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", andOption<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 shapeSignerAuthClient::lookupalready calls (signer_auth.rs:88). Must return the fullSignerKeyInfothe 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 syntheticpartner_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 + bodyJwsReplayClient::checkcalls (jws_replay.rs:65):{ partner_id: Uuid, jti: Uuid, iat: i64 }, semantics200=Ok, 401=Replay. Because the unchanged client sends onlypartner_id(=the constant nil synthetic)`jti`iat—kidis NOT in the payload — dedup is keyed byjti(not(kid,jti));jtiis a per-submission random UUID, so cross-holder collision is cryptographically negligible.iatbounds the retention window. -
POST /keys/register— accepts a browser-generated public JWK + a holder label → storespending(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 (acraig-intake-keyringsecret env var; absent ⇒ routes 401). NB:SignerAuthClientcaches 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_signedhandler (noapi_key_middleware, noExtension<ValidatedApiKey>): it (1) extractsX-JWS-Signature; (2) verifies the detached JWS against the keyring viaSignerAuthClient(status"active"); (3) replay-checks viaJwsReplayClient(dedup byjti, D8) — no partner-id binding (no tenancy); (4) runsprepare_submissionwith the FULL reporter-type set; (5) callsShinesSink.forward. -
prepare_submissiongains an allowed-reporter-types parameter (aReporterTypeSet/Channel):PublicForm → PUBLIC_REPORTER_TYPES;SignedApi → all ReporterType(soprofessional/law_enforcement/self_report— the real SHINESrepTypesubmitters — are accepted, not 422’d). The public form path andsubmit_report_partnerkeep the public subset. -
Do NOT mount the cases-bound partner status + attachment routes (
partner.rsstatus,attachments.rs) — they requireCasesForwarderSink/cases, absent inShines. -
The three SDK clients gain a signature-only submit method (send
X-JWS-Signature, omitX-Api-Key;client.rs:63,client.ts:99,client.py:94). OpenAPI: add anX-JWS-Signaturesecurity scheme for the signed route (api/mod.rs:29). -
The keygen + registration UI is copied into intake’s static tree (
ui.rsserves via crate-relativeinclude_str!— a cross-service reference tocraig-web’s `craig-sign.js/key-registration.jswon’t compile) and added file-AND-route. It posts same-origin to an intake proxy routePOST /signed/v1/keys/register, which forwards server-side to the sidecarPOST /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_email → relevant_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 |
|
|
|
NEW: |
|
NEW: rule engine + |
|
|
|
typed views, schema endpoint, sig-auth |
|
schema driver, keygen/registration UI |
|
NEW sidecar service (flat-file registry + replay) |
|
lockstep DTO/enum additions; |
|
5 SHINES crosswalks (placeholder) + |
|
SHINES CPS-intake mock route |
|
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)
-
#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
ssnandrecord_id. -
#692 — authoritative SHINES RefVals numeric tables (raceId/maritalStatusId/langId/repType); placeholders pinned in appendix B; swap in
crosswalk.rswith zero logic change. -
#693 — confirm the SHINES required-field set; the docx has no required/optional flags; the manifest in
fidelity.rsis a conservative stub.
Documentation Updates
-
ADR-042 finalize edits: the
ExtraFieldstop-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; thecraig-intake-keyringsidecar page; intake architecture;services.adocindex (intake row + the new sidecar + port 8010). -
CHANGELOG.adoc == Unreleased— incl. theChangedentry forReportConfirmation→TrackingReference. -
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: incidentDate←incident_datetime; county←reporter_county; location←[core]
incident_location; reporterRelationship←reporter_relation; primaryCaretaker(+Address+Relationship)
←[EF]; secCaretaker(+Address)←[EF]; firstHandInfo←[EF]; isInfosterCare/fosterCareName/fosterCareAddress
←[EF] ([XW] bool→Y/N); familyName←[EF]; narrative←concern_description (post-sanitize); dateReported
/intakeDate←[svr] submit-time; emergencyInfo←safety_concerns; reporterContactNumber←[core]
reporter_phone (truncate 12); isInMilitary←is_in_military (captured bool, [XW] →Y/N — NOT derived from adults[]);
isInMilitaryParent←[EF];
isPreviouslyReported←[EF]; isNaorit/isNaoritComments←indian_heritage/indian_heritage_details;
isAnyoneIllComments←[EF].
nine question* (all [narr]): questionMalNegOrAbuse←abuse_description; questionNegOrAbuseHarm←
harm_description; questionHowDoYouKnow←awareness_source; questionWhenLastOccur←
maltreatment_last_occurred; questionMalAccessChild←maltreater_has_access; questionWhereIsChild←
children_location; questionFswsComments←additional_comments; net-new in NarrativeView+SDK:
questionOccurAgain←occur_again; questionChidDanger←child_danger.
reporterInfo: emailId←reporter_email; firstName/lastName←reporter_first_name/_last_name;
telephone←reporter_phone; repType←reporter_type(+mandated_reporter_category); middleName/
mobileNumber/rptrTitle/rptrOrgnization*← [EF] (optional; absent today → empty).
motherInfo/fatherInfo (D4: by shines_household_role): firstName/lastName←[adult]; dob←dob;
ssn←ssn (string); raceId←[XW]race; maritalStatusId←[XW]marital_status;
langId←[XW]language; address←address; contactNumber←contact_number;
altContactInfo←alt_contact_info; isAllegedMaltreator←[XW]is_alleged_maltreater.
otherHouseholdInfo[] = remaining adults + relToPrimaryCaretaker←rel_to_primary_caretaker,
isOtherMember←[XW]is_other_member.
chindInfo[]: firstName/lastName←[child]; dob←date_of_birth; ssn←ssn (string);
isVictim←[XW]is_victim; sex←[XW]gender; raceId←[XW]race; grade←grade.
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.)