Plan: craig-intake Interface — Hardening & Total Test Coverage

On this page

Status

Step Description Status

1

#747 fix — revoked/expired signer key keeps verifying ~60s (signer-key positive cache)

Done (2026-06-28) — MR !841

2

#748 fix — edge UI accessibility defects (dark-mode contrast, focus/announce, live regions, target size, forced-colors)

Done (2026-06-29) — #748; ships with Step 10 as one a11y MR (fix + its audit oracle). See the Step 2 as-built note

3

#749 fix — enforce + prove free-text sanitization reaches the forwarded SHINES CpsRequest

Done (2026-06-28) — #749: an end-to-end CpsRequest sanitization proof was added; the sanitize→merge→mapper→forward path was verified sound (test-only — no code gap, see the Step 3 as-built note)

4

#750 fix — input-integrity hardening (deny_unknown_fields, byte-vs-char length, admin_unit, silent fallbacks, attachment/JWK)

Done (2026-06-28) — MR !843 (DTO/validation refactor + admin_unit) + MR !844 (5 contained fixes); closed #750

5

#743 fix — SHINES adult silent-drop / unsatisfiable family (maltreater relationship + relationship_to_child + signed-path 400)

Done (2026-06-28) — MR !842

6

#751 test — happy-path forwarding coverage (assert the forwarded cases/SHINES body)

Done (2026-06-29) — #751; forwarding assertions at the layer each body is observable (cases-sink unit + mapper + SHINES debug-panel tests/api + e2e). See the Step 6 as-built note

7

#752 test — sad-path validation coverage at the HTTP edge

Done (2026-06-29) — #752; rejection assertions at the layer each is reachable (sink-unit MapError→422, validation-unit gender reject, HTTP-edge required-when-relevant / ordering / refval / curated-out). See the Step 7 as-built note

8

#753 test — evil/security coverage (corpus on signed/partner, rate-limit, CAPTCHA, CORS, body-limit, keyring, PII-in-logs)

Done (2026-06-29) — #753, 3-MR program. MR-1 in-process middleware harness (EVIL-04/05/06/10/11, !850); MR-2 corpus breadth (EVIL-01/02/14, !853); MR-3 honeypot-no-forward / keyring replay preseed+window / signed SSRF structural / COMP-02 capture_logs helper (EVIL-09/12/13 + COMP-02, final). Two product 500s the corpus surfaced were fixed first: #905 (intake sanitize) + #906 (crypto self-describing envelope)

9

#754 test — chaos/fault-injection coverage (cases sink, keyring outage, signed replay, attachment faults, concurrency)

Done (2026-06-30) — #754, 4-MR program closing all 10 gaps: MR-1 outbound-fault redaction (CHAOS-01/04/06 + COMP-11 + the spawn_oidc lift to craig-mock-server, !856), MR-2 intake concurrency/cache (CHAOS-03/07/10, !857), MR-3 keyring restart durability + capacity race (CHAOS-08/09, !858), MR-4 signed concurrent exactly-once (CHAOS-05, !859). CHAOS-04 surfaced a real info-leak (attachment relay verbatim) filed + tracked as #933. See the Step 9 as-built note

10

#755 test — accessibility audit coverage (dark + high-contrast + forced-colors, all states, WCAG 2.2 tags, incomplete, integrated)

Done (2026-06-29) — #755; the audit oracle for Step 2, shipped in the same MR (77 pages, 0 violations). See the Step 10 as-built note

11

#756 test — cross-cutting coverage (keyring sidecar, OpenAPI drift, i18n negotiation, browser crypto, visual regression)

Done (2026-06-30) — #756, 5-MR program closing the ten owned gaps. MR-1 keyring COMP-07/08/10/12 (!861); MR-2 intake OpenAPI/i18n/schema COMP-01/03/13 (!862); MR-3 cross-lang SDK parity COMP-04 (!863); MR-4 in-process TLS enabler + ADR-046 COMP-05-enabler (!864); MR-5 browser-crypto COMP-05 + visual-regression COMP-06 + the devstack HTTPS instance (final). As-built deviations: COMP-04 golden is verified by the SDK signature primitives (not the server, whose 5-min iat window rejects a committed golden); COMP-12 is a characterization (register does no expires_at validation); the COMP-04 refval vector excludes the #692-placeholder numeric crosswalks; the signed path emits no debug panel (ADR-042 §D9), so the COMP-05 browser-crypto submit asserts the success view + numeric SHINES record id. See the Step 11 as-built note.

Epic: &61 — craig-intake interface: total test-coverage + hardening
Branch: one feature/{desc} branch + MR per step (exception: Steps 2 + 10 ship as one MR) — the plan body is docs, but each step’s MR ships its code/tests + the matching doc updates (see Documentation Updates)
Labels: fix/test + craig-intake + priority, per child issue
Dependencies: Steps 2 + 10 ship as a single a11y MR (the fix + its audit oracle — an explicit exception to one-MR-per-step). Step 4’s deny_unknown_fields must NOT land before Step 5 removes relationship_to_child from the SHINES payload (it would otherwise reject valid SHINES adult submissions that still send the key) — order Step 5 before Step 4, or ship them together.

Context

A 14-agent verification workflow audited the craig-intake interface on 2026-06-28 (main cb3a30c3) across five dimensions — happy, sad, evil, chaos, accessibility — covering both backend profiles (none/cases and shines), both deploy modes (integrated and standalone), the UI, and the craig-intake-keyring sidecar.

Audit method (each stage fed the next):

  • surface + existing-test inventory;

  • per-dimension gap analysis;

  • adversarial verification of every claimed gap;

  • completeness critic.

It confirmed 76 gaps: 51 REAL-GAP + 12 PARTIAL + 13 cross-cutting (one claim was rejected as already-covered). 21 of the 76 are shipping product defects, not merely missing tests. Two findings were known going in and are folded in: the accessibility audit’s blind spots + the header dark-mode contrast defect, and the SHINES alleged-maltreater unsatisfiable field (#743).

Each child issue under &61 carries its verified gap IDs with file:line evidence and a concrete remediation per item; this plan is the program + sequencing over those issues. Per-step file/struct detail lives in the linked issue (cited as Issue: in each step).

Scope

In scope
  • The craig-intake service (HTTP surface, validation, the SHINES mapper/extra/narrative/sink/fidelity/signed paths, config/rate-limit/captcha/cors/i18n), its UI (templates + static JS), and the craig-intake-keyring sidecar.

  • Both backend profiles (none/cases, shines) and both deploy modes (integrated, standalone).

  • The 21 product-defect fixes and the per-dimension test-coverage backlog.

Out of scope
  • craig-cases and the external SHINES system internals beyond the intake forwarding boundary (intake asserts what it forwards, not downstream persistence).

  • Open design questions tracked separately: the AFCARS Gender option set (#744), the Spanish es catalog (#722), the central partner-key registration home (#723).

  • Adding new fields to the SHINES CpsRequest wire contract — gated on the authoritative SHINES contract (#691); see Step 5.

  • Generic platform-stab failure-path coverage already owned elsewhere (intake-specific fault paths only).

Approach

Two phases. Phase 1 ships the confirmed product defects first (they are live bugs, P1s before P2s); Phase 2 closes the coverage backlog by dimension.

Per-step rules:

  • Each step is an independently shippable unit on its own feature/{desc} branch with one MR (Closes #N), the full pre-push battery, and a fresh J1–J8 pass — except the coupled pairs below.

  • A bug discovered while implementing a step is filed as a separate fix: issue and /relate-d — never folded into the step’s MR.

  • Each step sets its own plan Status row to Done (YYYY-MM-DD) — <MR> on merge. Per git-workflow § Multi-MR Plans, only the final MR (Step 11) updates .claude/CLAUDE.md status/Known-Issues.

Sequencing:

  • Step 1 first — it is an authorization-correctness defect on the signed channel.

  • Steps 2–5 — the remaining product defects (child-safety-facing a11y, data-to-SHINES integrity, input integrity).

  • Phase 2 follows, security-bearing evil/chaos coverage (Steps 8–9) ahead of the lower-risk polish.

  • Couplings: (1) Steps 2 + 10 ship as ONE MR — Step 2’s acceptance oracle IS Step 10’s audit additions, so a standalone Step 2 MR can’t prove itself. (2) Step 4’s deny_unknown_fields lands only after (or with) Step 5’s removal of the SHINES relationship_to_child key — otherwise it rejects valid SHINES adult submissions still sending it.

Phase 1 — Product-defect fixes

Step 1 — #747 Signer-key revocation latency (P1, security)

Issue: #747. Resolved (Done — MR !841; ADR-045): the Problem below is the as-was, kept for the audit trail; the split positive/negative TTL + cache tests landed.

Problem (as-was): SignerAuthClient (services/craig-intake/src/api/signer_auth.rs — the signer-key cache; note partner_auth.rs is a different cache, for partner credentials) caches the signer-key lookup for 60 s on std::time::Instant with no invalidation path, so a revoked or expired key keeps verifying signed mandated submissions for up to a minute. A single const CACHE_TTL (signer_auth.rs:20) covers both the positive (approved) and negative (404/pending) cached entries. The cache module has zero tests, and the boundary test cannot be written today — the TTL is a fixed const with no injectable clock or mock-clock dev-dep.

Decisions (made here; ratify the window via a short ADR):

  • Mechanism — split the single shared CACHE_TTL into separate positive/negative TTLs (or add a positive-specific TTL), reducing only the positive (approved) lifetime to a small bounded window (default ≤ 10 s) while preserving the negative-cache duration (60 s). Full evict-on-revoke (a keyring→verifier revocation signal) is deferred as a separate issue. A short ADR records the accepted positive window (a child-welfare signing system may want it near-zero).

  • Test seam — make the TTL injectable (e.g. SignerAuthClient::with_ttl(…​)) so the boundary test sets a tiny TTL; no real sleep, no new dependency.

Acceptance criteria:

  • The shared CACHE_TTL is split so the positive (approved) lifetime is ≤ the ADR-ratified window while the negative (404/pending) duration is explicitly preserved.

  • Cache-module unit tests cover the positive, negative, and post-revocation transitions via the injectable TTL.

  • An ADR captures the revocation posture (extend ADR-042 or a new ADR).

Step 2 — #748 Edge UI accessibility defects (P1)

Issue: #748. Ships as ONE MR with Step 10 (its acceptance oracle); this is the product side of seed-finding K1.

Problem — confirmed WCAG defects in the edge UI (both modes), each its own deliverable:

  • Header nav a uses --primary → 2.0:1 in dark mode (fails WCAG 1.4.3; --link gives 8.32:1).

  • The post-submit success view neither moves focus nor announces the report id.

  • status.html / keys.html / keygen.html async result + error boxes have no role/aria-live.

  • Small nav/step targets fail WCAG 2.2 target-size (2.5.8).

  • No field-level aria-invalid / aria-describedby / aria-required.

  • Busy states (submitting/uploading/generating/checking) lack aria-busy.

  • Status badges / the review alleged-victim tag may rely on colour alone.

  • forced-colors (Windows High Contrast) unsupported.

Acceptance criteria: each defect above remediated in templates/CSS and verified green by the Step 10 audit additions delivered in the SAME MR (dark + high-contrast scans, success-view focus/announce, live regions, target-size).

As-built (2026-06-29, #748). All defects remediated in the 4 edge templates + intake-public.css + the two Alpine controllers (report-form.js, key-registration.js), with ui.rs unit guards pinning the nav-link token + the forced-colors block. Two as-built decisions:

  • Live regions are persistent wrappers, not x-show-converted boxes. Several async boxes deref a nullable object (status.html’s `result.id); under x-show that throws while hidden. So each announce-on-change box is wrapped in an always-present role+aria-live container around the existing <template x-if> (the SR observes the region before content is inserted) rather than converting the box itself to x-show. Whole-block reveals (success view, keygen done) use the heading focus-move idiom instead.

  • Field labels: the row controls nest the input inside <label> (implicit association), not :id/:for. The SHINES and None per-profile blocks bind the same x-model fields, so explicit ids would collide across the two x-show blocks (an axe duplicate-id-active risk). Nesting needs no ids, survives both profiles, and preserves the positional e2e selectors (.adult-entry input). The 4 validated row controls additionally carry per-row aria-invalid/aria-describedby keyed by a (stepErrorField, stepErrorIndex) pair.

  • A11Y-13 was a test-gap, not a product defect — the review tags + status/key badges already carry their state as visible text, so no markup change; the non-colour assertion (a computed forced-colors border ≥1px) is the Step-10 deliverable. The forced-colors border is added only to the background-bearing badges (not the inline .review-tag).

Step 3 — #749 Prove SHINES free-text sanitization (P1)

Issue: #749.

Problem: no test proves free-text (extras/narrative/person) sanitization is applied to the conformed CpsRequest that is actually forwarded to SHINES — on EITHER forward path. There are two: the public merge_shines_extra flow, and the signed endpoint’s separate merge (signed_submit.rs), which mandated SHINES submissions use.

Acceptance criteria: end-to-end assertions on BOTH paths — the public path (validation → merge_shines_extra → mapper → sink) AND the signed path (signed_submit.rs merge → sink) — that injected control/markup content is sanitized in the forwarded body; harden whichever path the assertion reveals a gap in.

As-built (2026-06-28, #749) — no code gap found; test-only. The path was traced end-to-end: prepare_submission sanitizes the body, the SHINES ExtraFields, AND the forwarded per-person/narrative free-text in place (validation.rs sanitize_submit_report_in_place / sanitize_extra_fields / sanitize_forwarded_freetext_in_place) BEFORE the raw value is built and the (now-sanitized) extra is merged — on both the public (merge_shines_extra) and signed (signed_submit.rs inline merge) paths. The ShinesSink parses its typed views from that sanitized raw, and the mapper copies only those views into CpsRequest (the ssn is the deliberate carve-out: sanitize leaves it byte-identical, the mapper digit-extracts it — J4). No field reaches CpsRequest from an unsanitized source.

Oracle (as-built). The public path is proven by an end-to-end assertion on the conformed CpsRequest itself, via the #713 debug-emit panel (intake_schema::shines_freetext_is_sanitized_in_forwarded_cps): a SHINES submit with HTML/script + NUL + ESC + an NFD payload + an SSN, asserting the forwarded CpsRequest has tags stripped, control chars removed, NFC-normalized, sentinels surviving, and the SSN digit-extracted. The signed nest emits no debug CpsRequest by design (signed_submit.rs debug_capture: None — the panel is the unsigned public-form path only), so it has no content oracle; it shares the identical prepare_submission sanitize + the identical ShinesSink mapper (both proven above), and its only path-unique code is the inline merge of the already-sanitized ExtraFields. It is covered by signed_submit::signed_submit_with_injection_payloads_sanitizes_and_forwards, which proves the signed path verifies → sanitizes → maps → forwards an injection-laden body (extras + narrative + person) without choking (a mapper that broke on a control char, or a merge that dropped the sanitized extras, would 500 instead of returning a tracking reference).

Step 4 — #750 Input-integrity hardening (P2)

Issue: #750. Sequencing: the deny_unknown_fields change lands only after (or with) Step 5’s removal of the SHINES relationship_to_child key — see Dependencies (Step 5 merged first, MR !842). Ships as two MRs (owner-approved): MR-1 the coupled DTO/validation refactor (unknown-key rejection + admin_unit + the SDK lock-step), MR-2 the five contained orthogonal fixes (byte→char, loud forward serialize, attachment filename, keyring JWK, kiosk PII-clear).

Problem — a cluster of input-integrity defects:

  • request structs lack #[serde(deny_unknown_fields)] (a mistyped field is silently dropped — the same class as #743);

  • concern_description min-length counts bytes, not chars;

  • admin_unit has no value/allowlist validation;

  • the public AND partner forward paths use to_value(body).unwrap_or(Value::Null) (silent failure — public.rs, partner.rs);

  • attachment filename/content-type are forwarded verbatim;

  • the keyring accepts an unvalidated public_key_jwk;

  • Submit-Another (resetForm) is not proven to clear prior-reporter PII before a second submission.

Decisions (made here; recorded as-built in the ADR-042 #750 amendment):

  • Unknown-field rejection is profile-aware, not a blanket deny. The submission body is multi-owner — the core SubmitReportRequest keys PLUS, under the SHINES profile, the 15 SHINES-only ExtraFields top-level keys (ADR-042 §D3; 935 added is_any_one_ill). A blanket [serde(deny_unknown_fields)] on the core contract would 400 every valid SHINES (and SDK-flatten) body. Two mechanisms instead: (a) per-entry [serde(deny_unknown_fields)] on the typed AdultEntry/ChildEntry views (a misspelled per-person key is a loud 400); (b) a top-level key-set diff in prepare_submission whose allowlist = the core key set (derived from to_value(body) — the contract has no skip_serializing_if, so the derived set is complete) unioned with SHINES_EXTRA_KEYS only under the SHINES profile. SHINES_EXTRA_KEYS lives in backend/shines/extra.rs (SHINES-owned, "strip cleanly"), pinned to the struct + cross-language fixture by a drift test. A [serde(flatten)] catch-all is rejected (utoipa renders it additionalProperties: true).

  • Widen the entry structs to the full None-form + SDK key set so the per-entry deny is safe: AdultEntry gains relationship_to_child/phone/email/county/is_primary_caregiver/dob_approximate; ChildEntry gains dob_approximate/age. age is capture-only (the chindInfo[] mapper has no age slot; DOB is canonical), range-validated 0..=21. The SDK Adult/Child mirror the additions.

  • admin_unit allowlist is jurisdiction-parameterized, all channels. When settings.jurisdiction resolves to a US state/territory, admin_unit is allowlist-validated against it on every submit channel (no partner exemption); a tribal/territory key (no AdminUnit table) is format-bounded only (Plan-S neutrality — never hardcode a state). reporter_county is NOT allowlisted (a reporter may be out-of-jurisdiction).

  • COMP-09 (kiosk reset) is defense-in-depth, not a live-bleed fix (MR-2, as-built). Implementation found both file <input>`s already sit inside Alpine `x-if blocks (#attach-file in the success view, #sign-key-file in the form), so Alpine destroys + recreates them empty on every "Submit Another" reset — there is no current cross-reporter file bleed, and the model fields were already cleared by blankForm(). resetForm() clears the two file inputs explicitly anyway (cheap belt-and-suspenders) and an e2e locks the post-reset no-residue invariant, so a future x-ifx-show refactor cannot silently reintroduce a bleed. The acceptance criterion’s "kiosk cross-submission PII-clear test" is satisfied by that invariant e2e.

  • EVIL-08 keyring JWK validation lands as a small extracted helper (MR-2). craig-intake-keyring register validates public_key_jwk as a P-256 key via a validate_signer_jwk helper (p256::PublicKey::from_jwk_str), proptest-covered (arbitrary JWK never panics); bogus inline fixtures migrated to real keys via craig_test_lib::signing::generate_test_keypair(). Recorded as-built in the ADR-042 #750 amendment B4.

Acceptance criteria: unknown TOP-LEVEL keys rejected per profile (the 15 SHINES extras accepted under SHINES, rejected under None) on all three channels; unknown PER-ENTRY adult/child keys rejected; proptest proves arbitrary unknown keys never panic and always 400; char-based min-length; admin_unit allowlist-validated under a resolved jurisdiction and format-only otherwise; ALL forward raw serializers (public + partner) make the Null fallback loud; attachment filename/content-type + JWK validated; a kiosk cross-submission PII-clear test.

Step 5 — #743 SHINES adult silent-drop / unsatisfiable family (P1)

Issue: #743 (see its resolution notes). Contract decisions recorded in the ADR-042 #743 amendment. Resolved (Done — MR !842; merged before Step 4 per Dependencies): the Problem + Decisions below are the as-was/as-built, kept for the audit trail.

Problem (as-was) — three defects on SHINES adults:

  • SAD-01maltreater_relationship_to_victim exists on AdultEntry (validation.rs:79) and is server-required when alleged, but the SHINES form never renders it and SHINES_ADULT_KEYS omits it → an alleged-maltreater submission is unsatisfiable from the SHINES form.

  • SAD-02 — the per-adult relationship_to_child is collected + sent under SHINES (it is in SHINES_ADULT_KEYS) but AdultEntry has no such field and no deny_unknown_fields, so serde silently drops it — entered data is lost.

  • SAD-13 — the maltreater cross-field 400 (server-required relationship) has no test on the channel that can reach it.

Decisions (made here):

  • SAD-01 — render maltreater_relationship_to_victim in the SHINES per-adult block + add it to SHINES_ADULT_KEYS (satisfiable). It is not mapped to the CpsRequest: PersonInfo/OtherHouseholdInfo (mapper.rs:258/283) have no relationship field, and adding one to the SHINES wire is gated on the authoritative contract (#691). The value reaches cases/audit, not the SHINES wire, until #691.

  • SAD-02stop collecting relationship_to_child under SHINES: drop it from SHINES_ADULT_KEYS AND gate the input out of the shared adult block under SHINES (x-show, report.html:335) so it isn’t collected-then-discarded. SHINES uses rel_to_primary_caretaker. Keep render + key for None/cases. Step 4’s deny_unknown_fields then makes any future silent drop loud.

Acceptance criteria:

  • The SHINES alleged-maltreater path is satisfiable end-to-end (field rendered + keyed + an intake-ui-shines e2e for the alleged-maltreater case).

  • relationship_to_child is neither rendered nor sent under SHINES (form input gated + removed from SHINES_ADULT_KEYS); a test pins that None/cases still renders + carries it.

  • A signed_submit_alleged_maltreater_without_relationship → 400 integration test (the signed channel reaches the cross-field rule).

  • An ADR-042 amendment records the SHINES adult-field forwarding decisions.

Phase 2 — Test-coverage backlog

Step 6 — #751 Happy-path forwarding coverage (P1)

Issue: #751 (gaps H1–H12).

Problem: happy tests assert only HTTP 200 + an id; almost nothing asserts the body forwarded to craig-cases (none) or SHINES.

Acceptance criteria: per the #751 checklist — forwarding-body assertions across profiles, modes, reporter types, multi-person reports, the success view, and the incident_datetime → incidentDate derivation. Trust the issue’s per-gap kind/severity (H2/H4 were corrected to test-gap there).

As-built (2026-06-29, #751). There is no single forwarded-body oracle, so each gap is asserted at the layer where its body is actually observable:

  • Rust unit (cases sink + SHINES mapper). A new cases_forwarder test module asserts the forwarded cases body field-by-field (partner context, reporter fields, narrative, children/adults/raw_submission verbatim), the anonymous-omits-reporter-keys case, the authenticated-partner-id case, and get_report_status (screened disposition / pending fallback / 404). The cases-forwarded body has NO oracle at the tests/api or e2e layers — the devstack container runs the capture-less router() — so a host-owned cases route was added to craig-mock-server (the cases twin of the SHINES mock; happy routes only, the existing global fault layer is #754’s seam). The reporter-type → SHINES repType crosswalk is asserted in the conformed CpsRequest for all six reporter types at the mapper.

  • tests/api (SHINES public, #713 debug panel). The forwarded CpsRequest core fields, the multi-person arrays, the 15 ExtraFields, and the incident_datetime → incidentDate date-only derivation are asserted via the debug panel. These use concerned_citizen: on the SHINES Signed profile the public edge accepts only {Anonymous, ConcernedCitizen} (mandated routes to /signed; law_enforcement/self_report/professional are never public), and the signed path emits no debug panel (ADR-042 §D9) — so the signed channel is proven to accept + forward each canonical reporter type, with the forwarded repType value covered by the mapper test above. The None path proves concerned-citizen acceptance + that the status response carries well-formed timestamps.

  • e2e. County forwarding (SHINES), the first integrated-mode submission + status round-trip, the success-view confirmation copy + Check-Status CTA (None), and view-keys metadata + a two-layer no-leak check (SHINES).

Two items needed no new work: H6 (unit) — the incident_date three-branch derivation is already covered by mapper::incident_date_branches, so the additive H6 work is the tests/api derivation proof; the H6 browser test was not written because the SHINES UI collects only a date (#incident_date), making the incident_datetime truncation API-reachable but not browser-reachable. No product defect surfaced (H2/H4 were test-gap, as the issue records).

Step 7 — #752 Sad-path validation coverage (P2)

Issue: #752.

Problem: validation paths are proven mostly at the mapper/unit level, not through the HTTP handler.

Acceptance criteria: edge-level tests for MapError → 422 conversions, required-when-relevant 422s, per-entry SSN/refval rejection, 400-vs-422 ordering, and the public-vs-signed reporter-type contrast.

As-built (2026-06-29, #752). 10 rejection tests across 5 existing files, each placed at the layer where its rejection is actually reachable — there is no single oracle:

  • Sink unit (backend/shines/sink.rs, SAD-03). forward() is called directly (it parses typed views from raw and maps before any POST, bypassing validation), so the MapError → 422 conversion (sink.rs unprocessable("submission", …)) is proven for a duplicate mother / non-numeric SSN / unmappable race.

  • Validation unit (api/validation.rs, SAD-09). The AFCARS male/female-only Gender reject is pinned for validate_children_array + validate_adults_array over {non_binary, other, unknown} (the generic invalid-gender path was already covered; this locks the specific cross-system values).

  • HTTP edge (tests/api). SHINES public 422s an unanswered is_in_military_parent when is_in_military=true (SAD-04) and surfaces the mandated-signing 422 ahead of a concurrent concern_type 400 (SAD-07, handler ordering); the signed channel 422s a non-numeric SSN at the mapper and 400s a bogus race at validation (SAD-08); the public form 400s a curated-out reporter type with the typed INVALID_ENUM_VALUE problem (SAD-10, the public-rejects half of the #639 contrast — the signed-accepts half already exists).

Three of the issue’s "Do" recommendations were corrected against source (a contextless implementer following them verbatim would write broken tests): the matches! target is ApiError::Validation, not a non-existent Unprocessable variant (unprocessable() constructs Validation); missing-required-parent is unreachable through forward() (the sink hardcodes fidelity::REQUIRED_PARENTS = {false, false}), so it stays covered by the existing mapper unit; and a bogus refval (race) is a 400 at the HTTP edge (validation and the mapper share Race::from_str and validation runs first), not the 422 the issue expected — the refval→422 conversion is pinned at the sink unit instead. Test-only; no product gap surfaced.

Step 8 — #753 Evil/security coverage (P1)

Issue: #753 (gaps EVIL-01…14 + COMP-02).

Problem: the evil-input corpus runs only against the public/none path and reaches ~4 fields.

Acceptance criteria: broaden the corpus to the signed + partner paths and the full user-controlled field set; HTTP-level tests for the rate limiters (429), enabled CAPTCHA, CORS, the body-size limit (413), keyring abuse; a log-capture test asserting no PII/SSN reaches logs.

Step 9 — #754 Chaos/fault-injection coverage (P1)

Issue: #754 (gaps CHAOS-01…11 + COMP-11).

Problem: intake-specific fault/concurrency paths are untested (the cases forwarder — the forever-backend — has zero fault tests).

Acceptance criteria: fault tests for the cases sink + keyring outage/negative-cache, signed-path replay exactly-once, attachment-proxy faults, the debug-capture slot under concurrency, keyring crash-recovery at the router level, and rate-limit races.

As-built (2026-06-30, #754). Shipped as a 4-MR program closing all 10 audit-verified gaps, grouped by test infrastructure:

  • MR-1 — outbound-fault redaction matrix (!856): CHAOS-01 (cases-forwarder fault matrix), CHAOS-04 (attachment-proxy faults, router-level), CHAOS-06 (signed replay-check outage), COMP-11 (signer-auth security-service outage). Lifted the duplicated spawn_oidc test helper into craig_mock_server::oidc::spawn_oidc (no new dependency).

  • MR-2 — intake concurrency & cache bounds (!857): CHAOS-03 (negative-cache TTL bound), CHAOS-07 (debug-slot isolation under concurrency), CHAOS-10 (rate-limit burst race). The races use concurrent_fire_synchronized (barrier-released) on a multi_thread runtime.

  • MR-3 — keyring restart durability & capacity race (!858): CHAOS-08 (restart preserves approval + replay state at the router level), CHAOS-09 (concurrent distinct-kid registration respects max_keys).

  • MR-4 — signed-path concurrent exactly-once (!859, this MR): CHAOS-05 (devstack-gated) — N parallel identical-JWS signed submits yield exactly one 200 + the rest 401.

Two as-built notes:

  • CHAOS-01 was narrower than the 2026-06-28 audit stated. The audit’s "CasesForwarderSink has no test module" was stale (Step 8 added a happy-path module); the fault matrix was the real, still-missing gap.

  • CHAOS-04 surfaced a real defect, filed separately (not fixed here). The attachment upload/download/delete proxy relays upstream error status + body verbatim (via relay_response), unlike list_attachments which redacts via redact_upstream_error — a likely info-leak on the unauthenticated public upload route. CHAOS-04 ships a characterization test locking the current behavior; the remediation is tracked as #933, which will flip that test to assert redaction. Per the bugs-mid-impl rule it was filed as a separate fix: issue, not folded into this tests-only program.

Step 10 — #755 Accessibility audit coverage (P2)

Issue: #755 (gaps A11Y-01/08/11/12/14). Ships as ONE MR with Step 2 (it is Step 2’s acceptance oracle).

Problem: the axe audit runs only in light-mode, on the happy initial load, with WCAG-2.0 tags, asserting only violations.

Acceptance criteria: scan dark + prefers-contrast: more + forced-colors; the success view + all wizard steps + status/keys/keygen states; add WCAG 2.1/2.2 tags AND explicitly enable the axe-core target-size rule (it is OFF by default — tagging WCAG 2.2 alone does not run it, so target-size coverage would be falsely claimed); surface incomplete; add keyboard-nav/focus-visible/skip-link checks; run the integrated-deploy edge UI — naming the integrated-edge URL env var + its xtask export so the sweep runs rather than silently skipping.

As-built (2026-06-29, #755). accessibility-audit.spec.ts runAxe gained a typed AxeOpts (tags / targetSize / media / failIncomplete / allowIncomplete); all axe config is in one options({runOnly, rules}) because options() overrides withTags(). Run green: 77 pages, 0 violations, 0 unresolved incompletes. Two as-built notes:

  • incomplete failing is scoped to the edge scans. failIncomplete gates only the edge calls (A11Y-08); the pre-existing caseworker/admin auditPage calls capture incomplete for the report but do not gate on it (broadening to those is out of scope). color-contrast incomplete is auto-allowlisted under forced-colors (axe cannot evaluate contrast when the OS overrides colors — expected, not a gap).

  • No xtask change was needed for the integrated sweep (A11Y-14). CRAIG_INTAKE_URL is already exported by xtask (docker.rs); the audit just consumes it. The non-default-jurisdiction palette sweep (needs a 2nd themed bundle instance = devstack infra) is out of scope and tracked as a follow-up: #902 (the single-palette integrated sweep landed here).

Step 11 — #756 Cross-cutting coverage (P1)

Issue: #756 (gaps COMP-01/03/04/05/06/07/08/10/12/13).

Problem: surfaces the dimension passes don’t own.

Acceptance criteria: the keyring sidecar (config matrix, health probes, register-expiry boundary, replay/store growth bounds); OpenAPI document validity/drift; intake-schema server/JS contract drift; i18n/Accept-Language negotiation at the edge; the real browser crypto.subtle ES256 signing chain; cross-language parity for signatures/refvals; visual-regression baselines for the edge pages.

Step 11 as-built (2026-06-30) — #756, 5-MR program

Delivered as five MRs off main, layered for bisect:

  • MR-1 (!861) keyring COMP-07/08/10/12 — config validate matrix, health probes, store-growth 429 via the route, register-time expires_at boundary (a malformed datetime is a 422 from Axum’s Json extractor, not 400). COMP-12 is a characterization: register does no expires_at validation (#[garde(skip)]).

  • MR-2 (!862) intake COMP-01/03/13 — OpenAPI document + operation-set pin + documented-vs-gated-router drift; i18n negotiation (unknown tags fall back to en; es IS a negotiable locale via the georgia terminology overlay, so #722 is the es-strings gap, not locale availability); intake-schema key+predicate contract. A small theme_contribution seam was added to RouterConfig (production passes None).

  • MR-3 (!863) SDK parity COMP-04 — a committed golden detached JWS verified at the signature level by all three SDK primitives (Rust p256 / Python joserfc / TS jose), NOT the server (whose 5-min iat freshness window rejects any committed golden); same-output adversarial canonicalization vectors; a STABLE-only refval crosswalk vector (excludes the #692-placeholder numeric *_id tables + reporter_type).

  • MR-4 (!864) the in-process TLS enabler + ADR-046 (COMP-05 enabler) — optional, default-off rustls TLS via axum-server (preserves ConnectInfo + graceful shutdown); self_signed_tls behind the self-signed-tls Cargo feature (devstack only, fail-closed when off).

  • MR-5 (!866) COMP-05 + COMP-06 — the devstack craig-intake-standalone-shines-tls HTTPS instance (built --features self-signed-tls via the CRAIG_INTAKE_FEATURES build arg), the intake-ui-shines-tls Playwright project, the real-browser-crypto spec, and the LFS visual baselines. The COMP-05 browser-signed (mandated→signed) submit asserts the success view + numeric SHINES record id — the #713 debug panel is PUBLIC-path only (ADR-042 §D9).

Verification

The plan is complete when every child issue under &61 is closed, each with its gap IDs mapped to a referenced test or fix. Per step:

  • the linked issue’s acceptance checklist is fully ticked;

  • cargo nextest run -p craig-intake -p craig-intake-keyring and the affected e2e projects (intake-ui, intake-ui-integrated, intake-ui-shines, accessibility) are green;

  • cargo xtask plan-lint + cargo xtask check-docs are green.

Whole-plan done-conditions:

  • the two seed findings are resolved — the edge UI passes axe in dark + high-contrast (Steps 2/10), and the SHINES alleged-maltreater path is satisfiable end-to-end (Step 5);

  • re-running the coverage-audit workflow is an optional regression aid, not the gate — the gate is the per-issue checklists + the commands above.

Documentation Updates

Each code-bearing step’s MR updates docs on its branch (Documentation Update Checklist):

  • CHANGELOG.adoc (== Unreleased) — every step’s MR.

  • Step 2 (a11y) — the accessibility/VPAT page(s) under docs/modules/ROOT/pages/ reflecting the fixed criteria.

  • Steps 4 + 5 (input-integrity, SHINES adult fields) — the SHINES interface page(s) under interfaces/ + the ADR-042 amendment recording the SHINES-forwarding-contract decisions.

  • Step 1 (signer-cache) — the ADR recording the revocation posture.

  • .claude/CLAUDE.md (Known Issues / status) — updated by the final MR only (Step 11) per git-workflow § Multi-MR Plans; each earlier step updates its own plan Status row on merge.

Risks & mitigations

Risk Mitigation

Shared report.html/report-form.js edits regress the other profile

Run all intake e2e projects (none + integrated + shines) as the regression gate on every step; widen gates additively.

Step 4’s deny_unknown_fields lands before Step 5 removes SHINES relationship_to_child → breaks SHINES adult submissions

Hard ordering: Step 5 (or at least its relationship_to_child removal) lands before/with Step 4’s deny_unknown_fields (see Dependencies); audit live client payloads first; a deliberate allowance is tested, not silent.

Accessibility fixes (Step 2) and a11y audit (Step 10) drift apart

They ship as ONE MR — Step 10’s new scans are the acceptance check for Step 2 (see Dependencies).

Scope creep from the open design questions (#744/#722/#723) or new SHINES wire fields

Explicitly out of scope; new SHINES CpsRequest fields are gated on #691; any new product decision is a separate issue/ADR, not folded into a step.

Edit this page · latest