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 |
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 |
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 |
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
escatalog (#722), the central partner-key registration home (#723). -
Adding new fields to the SHINES
CpsRequestwire 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.mdstatus/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_fieldslands only after (or with) Step 5’s removal of the SHINESrelationship_to_childkey — 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_TTLinto 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_TTLis 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 auses--primary→ 2.0:1 in dark mode (fails WCAG 1.4.3;--linkgives 8.32:1). -
The post-submit success view neither moves focus nor announces the report id.
-
status.html/keys.html/keygen.htmlasync result + error boxes have norole/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); underx-showthat throws while hidden. So each announce-on-change box is wrapped in an always-presentrole+aria-livecontainer around the existing<template x-if>(the SR observes the region before content is inserted) rather than converting the box itself tox-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 samex-modelfields, so explicit ids would collide across the twox-showblocks (an axeduplicate-id-activerisk). Nesting needs no ids, survives both profiles, and preserves the positional e2e selectors (.adult-entry input). The 4 validated row controls additionally carry per-rowaria-invalid/aria-describedbykeyed 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_descriptionmin-length counts bytes, not chars; -
admin_unithas 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
SubmitReportRequestkeys PLUS, under the SHINES profile, the 15 SHINES-onlyExtraFieldstop-level keys (ADR-042 §D3; 935 addedis_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 typedAdultEntry/ChildEntryviews (a misspelled per-person key is a loud 400); (b) a top-level key-set diff inprepare_submissionwhose allowlist = the core key set (derived fromto_value(body)— the contract has noskip_serializing_if, so the derived set is complete) unioned withSHINES_EXTRA_KEYSonly under the SHINES profile.SHINES_EXTRA_KEYSlives inbackend/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 itadditionalProperties: true). -
Widen the entry structs to the full None-form + SDK key set so the per-entry deny is safe:
AdultEntrygainsrelationship_to_child/phone/email/county/is_primary_caregiver/dob_approximate;ChildEntrygainsdob_approximate/age.ageis capture-only (thechindInfo[]mapper has no age slot; DOB is canonical), range-validated0..=21. The SDKAdult/Childmirror the additions. -
admin_unitallowlist is jurisdiction-parameterized, all channels. Whensettings.jurisdictionresolves to a US state/territory,admin_unitis allowlist-validated against it on every submit channel (no partner exemption); a tribal/territory key (noAdminUnittable) is format-bounded only (Plan-S neutrality — never hardcode a state).reporter_countyis 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-ifblocks (#attach-filein the success view,#sign-key-filein 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 byblankForm().resetForm()clears the two file inputs explicitly anyway (cheap belt-and-suspenders) and an e2e locks the post-reset no-residue invariant, so a futurex-if→x-showrefactor 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-keyringregistervalidatespublic_key_jwkas a P-256 key via avalidate_signer_jwkhelper (p256::PublicKey::from_jwk_str), proptest-covered (arbitrary JWK never panics); bogus inline fixtures migrated to real keys viacraig_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-01 —
maltreater_relationship_to_victimexists onAdultEntry(validation.rs:79) and is server-required when alleged, but the SHINES form never renders it andSHINES_ADULT_KEYSomits it → an alleged-maltreater submission is unsatisfiable from the SHINES form. -
SAD-02 — the per-adult
relationship_to_childis collected + sent under SHINES (it is inSHINES_ADULT_KEYS) butAdultEntryhas no such field and nodeny_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_victimin the SHINES per-adult block + add it toSHINES_ADULT_KEYS(satisfiable). It is not mapped to theCpsRequest: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-02 — stop collecting
relationship_to_childunder SHINES: drop it fromSHINES_ADULT_KEYSAND gate the input out of the shared adult block under SHINES (x-show, report.html:335) so it isn’t collected-then-discarded. SHINES usesrel_to_primary_caretaker. Keep render + key for None/cases. Step 4’sdeny_unknown_fieldsthen makes any future silent drop loud.
Acceptance criteria:
-
The SHINES alleged-maltreater path is satisfiable end-to-end (field rendered + keyed + an
intake-ui-shinese2e for the alleged-maltreater case). -
relationship_to_childis neither rendered nor sent under SHINES (form input gated + removed fromSHINES_ADULT_KEYS); a test pins that None/cases still renders + carries it. -
A
signed_submit_alleged_maltreater_without_relationship → 400integration 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_forwardertest 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, andget_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-lessrouter()— so a host-owned cases route was added tocraig-mock-server(the cases twin of the SHINES mock; happy routes only, the existing global fault layer is #754’s seam). The reporter-type → SHINESrepTypecrosswalk is asserted in the conformedCpsRequestfor all six reporter types at the mapper. -
tests/api (SHINES public, #713 debug panel). The forwarded
CpsRequestcore fields, the multi-person arrays, the 15 ExtraFields, and theincident_datetime → incidentDatedate-only derivation are asserted via the debug panel. These useconcerned_citizen: on the SHINESSignedprofile 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 forwardedrepTypevalue 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 fromrawand maps before any POST, bypassing validation), so theMapError → 422conversion (sink.rsunprocessable("submission", …)) is proven for a duplicate mother / non-numeric SSN / unmappable race. -
Validation unit (
api/validation.rs, SAD-09). The AFCARS male/female-onlyGenderreject is pinned forvalidate_children_array+validate_adults_arrayover{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 unansweredis_in_military_parentwhenis_in_military=true(SAD-04) and surfaces the mandated-signing 422 ahead of a concurrentconcern_type400 (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 typedINVALID_ENUM_VALUEproblem (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_oidctest helper intocraig_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 amulti_threadruntime. -
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/deleteproxy relays upstream error status + body verbatim (viarelay_response), unlikelist_attachmentswhich redacts viaredact_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 separatefix: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:
-
incompletefailing is scoped to the edge scans.failIncompletegates only the edge calls (A11Y-08); the pre-existing caseworker/adminauditPagecalls captureincompletefor the report but do not gate on it (broadening to those is out of scope).color-contrastincompleteis auto-allowlisted underforced-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_URLis 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
|
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-keyringand the affected e2e projects (intake-ui,intake-ui-integrated,intake-ui-shines,accessibility) are green; -
cargo xtask plan-lint+cargo xtask check-docsare 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 |
Run all intake e2e projects (none + integrated + shines) as the regression gate on every step; widen gates additively. |
Step 4’s |
Hard ordering: Step 5 (or at least its |
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 |