Plan: craig-intake Portal — Phase 4 (Mandated-Reporter Signing)

On this page

Phase 4 of the single-public-portal program (epic &60). Status: complete — all four steps merged (P4.1 #727, P4.2 #728, P4.3 #729, P4.4 #730); this plan is archived. Each issue shipped as its own feature/ branch + MR (full pre-push validate + a fresh J1–J8 pass; the #676 keepalive on push). The final MR flipped the program-brief P4 row → Done and archived this plan. The decisions are recorded in an amendment to ADR-043. Hardened through three contextless reviewer passes + a code-grounded owner review; the findings (the JWS header contract, the discarded signer evidence, the kid-less key file, the required category, the honest scope) are folded in.

Context — the gap

  • A mandated reporter (teacher, physician, law-enforcement — legally required to report) is an identified, accountable source. The L4 constraint: their reports must be cryptographically signed, enforced server-side — the UI branch is convenience, never the gate (program brief §L4).

  • The bypass is open today: the unauthenticated public channel accepts a self-declared mandated reporter with no proof (services/craig-intake/src/api/validation.rs PUBLIC_REPORTER_TYPES).

  • Signing must be accountable, not just verified. The existing /signed/v1/reports verifies a JWS then throws the evidence away (backend/shines/signed_submit.rs forwards no signer data; SHINES has no report store). A signature nobody records proves nothing — Phase 4 must persist it.

  • Scope (decision, 2026-06-27): SHINES-only + a soft gate. Build on the existing SHINES signing stack; enforce the gate only where a signing channel is wired (the Signed backend). This closes the bypass on SHINES deployments; integrated/cases-backed keeps accepting unsigned mandated until a deferred IdP-based follow-up (overlaps ADR-044 §3). L4 is therefore satisfied for SHINES now, globally only once that follow-up lands — stated honestly. Phase 5 is not gated on it.

  • Why the signature is recorded at the edge, not forwarded (the SHINES reality, owner, 2026-06-27). SHINES has no validating backend API — its insertion rules only ever existed client-side in the legacy worker UI, so a faithful server-side API cannot exist. The mandatory-reporting integration therefore does not call an API: it writes a JSON document to a fileserver, and a UiPath RPA bot re-keys that document into the legacy worker portal UI, letting the original client-side validation run. Consequences for Phase 4: (a) a bot typing into a UI has nowhere to put a detached signature, so the JWS’s value is entirely CRAIG-side accountability (recorded by P4.1, never forwarded — confirming the no-ForwardRequest.signer decision); (b) craig-intake’s validation is effectively the only real validation in the chain before the report is re-keyed; (c) the synchronous SHINES "record id" is a convenience fiction of the mock (the bot creates the record asynchronously) — the IntakeSink abstraction already hides this, so Phase 4 hardcodes none of it. The real file-drop transport is deferred; for initial testing the conformed-document-to-screen output is sufficient (see Documentation + follow-ups (2)).

Surface at a glance — mostly reuse, a contained delta

Already exists (reused): the signed endpoint /signed/v1/reports, the craig-intake-keyring register→approve→revoke lifecycle + replay store, the /keygen + /keys pages, the browser keypair-gen helpers (edge craig-sign.js), and the Rust SDK signer whose header contract the browser must match (crates/craig-intake-sdk/src/signing.rs). craig-web’s canonicalize + loadPrivateKey are reusable as-is; its signPayload is not (wrong header — see P4.2).

New / changed in Phase 4:

  1. Stop discarding the verification — record who signed (a PII-free report.signed audit event); the actual non-repudiation value. (P4.1, backend)

  2. A browser signer matching the SDK header (alg,kid,jti,iat), a type=module bridge, the mandated form-branch, a required category, and a kid-bearing key file. (P4.2)

  3. Approval-status UX driven off the per-kid active lookup + the submit-time 401. (P4.3)

  4. A structural server gate: Signed/public rejects mandated. (P4.4, last)

No new endpoint, key store, or crypto primitive.

Status

Step Description Status

P4.1 signer-record

/signed/v1/reports records the verified signer (PII-free report.signed audit event) instead of discarding it

Done (2026-06-27) — #727

P4.2 signing-UI

SDK-contract browser signer + bridge; mandated branch → /signed; required category; kid-bearing key file

Done (2026-06-27) — #728

P4.3 approval-UX

Per-kid status detection, graceful "awaiting approval" handling, key-file re-import (kid + jwk)

Done (2026-06-27) — #729

P4.4 server-gate

Structural gate: Signed/public rejects mandated (runs last)

Done (2026-06-27) — #730

Epic: &60 · Issues: P4.1 #727 · P4.2 #728 · P4.3 #729 · P4.4 #730 (+ deferred follow-ups #731, #732) · Sequencing (as delivered): P4.1 → P4.2 → P4.3 → P4.4 — gate last (flipping it before the UI existed would reject SHINES mandated reporters with nowhere to sign; the P3.6 embed_ui-last lesson). P4.1 was backend-only and landed first so the very first signed submission was already recorded. Branch: feature/{desc} per issue.

Decisions (→ ADR-043 amendment)

  1. Reuse the SHINES /signed + keyring — no new handler or key store.

  2. Accountability via a recorded signer. P4.1 captures the JwsVerification (instead of discarding it) and emits a structured report.signed audit event — PII-free by construction: the opaque signer/key ids (kid, signer_key_id), the replay/freshness anchors (jti, iat), the content binding (canonical-payload SHA-256
    signature), and the tracking id. The signer’s human identity (user_identifier) is deliberately not logged — it resolves from the kid in the keyring. Nothing new ships to SHINES (existing report fields only), so there is no ForwardRequest.signer plumbing. The signer↔reporter identity match (does the signature belong to the person the report claims to be from?) is the enforcement built next — a #691-gated follow-up (it needs the SHINES-issued unique reporter id, which our proposed reporterInfo schema does not yet carry).

  3. The browser signer matches the SDK, not craig-web. Intake’s verifier requires jti+iat in the protected header; the SDK builds {alg,kid,jti,iat}. Port craig-web’s canonicalize + loadPrivateKey, but write the signer to the SDK header (browser-generated jti UUID + epoch iat).

  4. The gate is structural, not a config knob — derived matches!(config.backend, Some(Signed(_))). You cannot be rejected from /public for a channel that does not exist.

  5. Client branches on the existing signal $store.relevance.backend_profile === 'shines' (as P3.5 did) — no new UiConfig field.

  6. The key file carries its kid. Keygen downloads { kid, privateKeyJwk } (today it drops the kid), so a returning reporter can re-import and sign. Pre-1.0, no production keys to migrate.

  7. Integrated signing is deferred (documented posture): integrated accepts unsigned mandated until the IdP-based follow-up; Phase 5 proceeds regardless.

Design

P4.1 — Record the signer (backend; lands first)

Goal: every accepted signed submission leaves a durable record of who signed — the actual point of mandated signing — without leaking any PII, and without changing what is sent to SHINES.

  1. Capture, don’t discard. verify_signed_jws returns a VerifiedSigner (the JwsVerification + the canonical-payload SHA-256 via craig-signing::hash_payload + the detached-JWS signature segment) to the handler, instead of dropping the verification.

  2. Emit a PII-free report.signed audit event after the report is accepted + forwarded: kid, signer_key_id, jti, iat, the payload hash, the signature, and the tracking id. The signer’s human identity (user_identifier) is not logged — it resolves from the kid in the keyring, so the record is PII-free by construction while still attributable. Re-verifiable later against the report in SHINES + the public key.

  3. No SHINES change. Nothing new is forwarded — the SHINES sink keeps mapping the existing report fields. No ForwardRequest.signer.

The signer↔reporter identity match is the enforcement built next, gated on #691 (the SHINES-issued unique reporter id — see Documentation + follow-ups).

Verify: an in-process unit test captures tracing and asserts the report.signed event carries the signer/key ids + content binding + tracking id and omits user_identifier (and any body); the existing verify-path tests (missing/malformed/outage/invalid-credential → uniform 401/500) are unchanged.

P4.2 — Signing UI (the mandated branch)

Goal: under SHINES, a mandated report is signed in-browser (to the verifier’s contract) and posted to /signed/v1/reports.

  1. Port the reusable helpers canonicalize + loadPrivateKey from craig-web into the edge craig-sign.js.

  2. Write signReport to the SDK contract — header {alg:'ES256', kid, jti, iat} where jti is a browser-generated UUID and iat is epoch seconds; detached JWS over the canonicalized body. Add a cross-language parity vector (browser canonicalize == craig-signing).

  3. Add report-sign.js (type=module): import the helpers, expose a frozen window.CRAIG_SIGN.signReport. Serve it + craig-sign.js on /report under the SHINES signing gate in ui.rs (today they mount only on /keygen). Signing fires on user submit, so the deferred module is always ready (keeps the Alpine load order).

  4. Branch the submit in report-form.js: when reporter_type === 'mandated' AND backend_profile === 'shines', sign and POST (method, headers: { content-type, x-jws-signature }, body = the exact signed bytes). Otherwise keep the current /public/v1/reports path. Minimal error message on non-200 (P4.3 enriches).

  5. Require the category. Add the conditional mandated_reporter_category <select> (15 options, mirror craig-web), include it in the payload, and validate it server-side in two layers (as-built): a non-blank value must be in a new VALID_MANDATED_CATEGORIES allowlist on every channel (in validate_top_level_enums, consistent with how reporter_type/concern_type are validated), and a separate required-when-mandated gate (require_mandated_category) called only by the interactive web-form handlers (public.rs + signed_submit.rs) — not the deliberately-permissive partner programmatic API, whose compliant integrators may legitimately omit it (a new required field there would be an unrelated breaking change). The field itself was already captured into raw_submission (#638 — already closed, MR !758); P4.2 adds the edge form <select> + the validation.

  6. i18nintake-sign-* keys in crates/craig-i18n/catalog/en/public.ftl + JS_I18N_KEYS.

Verify: the browser signing chain (crypto.subtle) requires a secure context the Playwright container lacks (it reaches the edge over http://host.docker.internal — see tests/e2e/specs/intake-keygen-signed.spec.ts), so (as-built, matching #690): the cryptographic round-trip is proved at the HTTP level — --project=intake-ui-shines (intake-keygen-signed.spec.ts) registers + approves a key and submits a signed mandated + category report (200) and a signed mandated without category report (400, post-auth). The browser branch is proved crypto-free — under SHINES, the category <select> is required to advance, the signing-key import block renders, and a mandated submit with no key loaded surfaces the key-required error (the signed branch fires before any crypto call). --project=intake-ui — under None, mandated stays on /public (asserted via the POST target). Unit — the canonicalization parity vector (craig-signing); the allowlist + required-when-mandated rules (validation.rs); the partner-path-does-not-require-category invariant.

P4.3 — Approval-gate UX

Goal: the register → pending → approve → sign journey is legible; an unapproved key fails gracefully.

  1. Per-kid status — detect approval via the keyring’s by-kid active lookup (GET /v1/security/signer-keys/by-kid/{kid}, proxied through intake): 200 ⇒ approved (sign); 404 ⇒ not active yet (pending/absent/revoked). Do not depend on the /keys list (enable_key_list is default-off and lists all keys — a view aid, not per-reporter status).

  2. Graceful failure — no key → "register first" (→ /keygen); not-active → "awaiting operator approval — check back" (→ /keys if enabled); the submit-time 401 is caught and mapped to the same guidance (uniform server 401, no oracle).

  3. Re-import (kid + jwk) — an "import your saved key" affordance that parses the P4.2 { kid, privateKeyJwk } file and signs. Private keys never touch the server.

  4. i18nintake-sign-status-* keys in public.ftl + JS_I18N_KEYS.

Verify: e2e --project=intake-ui-shines — a pending-key mandated submit shows "awaiting approval" (no raw 401); after the keyring approves the key, the same imported key signs + succeeds.

As-built (#729, 2026-06-27)
  • By-kid proxy (server). A new same-origin GET /signed/v1/keys/by-kid/{kid} (signed_submit.rs::lookup_signer_key_status, mounted in signed_routes) forwards to the keyring’s by-kid ACTIVE lookup. The keyring’s by-kid route is itself unauthenticated (no admin gate — it returns approved+unexpired keys only), so the proxy needs no token (a plain forward_get, like the #714 list proxy). The proxy returns a minimal SignerKeyStatusResponse { kid, status: "active" } on 200 — it does not relay the keyring’s holder identity / public JWK (the UI needs only the active bit); a not-active kid is a bare 404 (no pending/revoked/expired oracle); a keyring 5xx is redacted to 500.

  • Client. report-form.js gains signKeyStatus ('' | checking | approved | pending | unknown): loadSignKey() calls checkSignKeyStatus() right after a successful import, and the submit-time 401 on the signed path maps to the same intake-sign-status-pending guidance. The status line is a dedicated .sign-key-approval block (state-modifier classes mirror the .key-status-* palette). The "no key → register first" affordance is a /keygen link in the signing block (the re-import affordance itself landed in P4.2).

  • Verification deviation (secure context, #690). The browser status flow can’t be e2e’d in the Playwright container: the by-kid fetch is reachable only after a key import, and crypto.subtle key import needs a secure context http://host.docker.internal:<port>; is not. So the proxy contract (pending 404 → approved 200, no identity leak) is proved at the HTTP level in intake-keygen-signed.spec.ts, the proxy mapping has Rust unit tests (status_proxy_*), and a crypto-free intake-ui-shines test asserts the four intake-sign-status-* keys resolve in the served JS bundle. Same pattern P4.2 used for the signing chain itself.

P4.4 — Server-side gate (runs last)

Goal: close the bypass under SHINES — defense-in-depth behind the UI branch.

  1. Derive mandated_signing_required = matches!(&backend, Some(BackendWiring::Signed(_))) at build_router entry — by reference, so the later match backend still consumes it.

  2. Layer Extension(MandatedSigningRequired(bool)) on the public nest (alongside the existing DebugEmitCps / BackendProfile extensions); submit_report reads it.

  3. Reject via a standalone validation::reject_unsigned_mandated_on_public(&body, required) check (mirroring the existing require_mandated_category pattern — not threaded into validate_reporter_type): when required and the reporter is mandated, return a typed 422 (ApiError::unprocessable("reporter_type", …)), no body echo, pointing the caller at /signed. A 422 (a VALID reporter type refused by channel) is deliberately distinct from the 400 validate_reporter_type raises for a malformed enum, so the two stay single-purpose and PUBLIC_REPORTER_TYPES is left untouched (mandated is still valid on /public off SHINES). The check runs in submit_report right after the honeypot, before CAPTCHA/validation, so a gated request fails fast. Add the 422 to the submit_report #[utoipa::path] responses (was 400/429) + an OpenAPI/test assertion.

Why last: under SHINES the gate goes live the moment Signed boots. Under SHINES, /public submit is the only public mandated ingress (attachments, status, partner API are not mounted under Signed), so this fully closes it there.

Verify: integration — SHINES /public + mandated → 422; /signed + valid JWS + mandated → 200; None/Partner /public + mandated → still 200 (the deferral invariant). e2e — both paths still green.

As-built (#730, 2026-06-27)

The Design above reflects the as-built gate (the standalone reject_unsigned_mandated_on_public 422 check — chosen over the earlier sketch of threading a bool into validate_reporter_type, which would have overloaded one function with two status/problem_type semantics). Two additional as-built points the Design does not capture:

  • Handler refactor (no behavior change). Adding the gate would have pushed submit_report past the 100-LOC function budget (B2), so its ForwardRequest-assembly tail was extracted into a private forward_public_report helper — keeping the handler a readable parse → guard → validate → hash → forward pipeline. submit_report 109 → 77 LOC; B2 stayed LOCKED (no lock ratchet).

  • Verification. Three validation.rs unit tests (422-when-required / allow-when-not-required / allow-non-mandated-when-required) + an intake-ui-shines e2e (SHINES /public + mandated → 422). The None-profile deferral invariant stays covered by the existing intake-embedded-ui mandated→/public→200 test, and /signed + valid JWS + mandated → 200 by the P4.2 keygen-signed e2e. Verified live: SHINES /public mandated → 422, anonymous → 200 (the gate is mandated-only).

Verification (whole phase)

cargo fmt --all && cargo clippy --workspace --all-targets --locked -- -D warnings
cargo nextest run -p craig-intake -p craig-intake-keyring
cargo xtask dev reload && cargo xtask e2e --no-refresh -- --project=intake-ui-shines --project=intake-ui
cargo xtask plan-lint && cargo xtask check-docs

The canonicalization parity vector (browser canonicalize == craig-signing::canonicalize_json) and an audit-event assertion (signer fields present, no PII) are the two cryptographic guardrails.

Documentation + follow-ups

  • ADR-043 amendment recording: (a) the structural soft-gate (rejection derived from the Signed wiring); (b) the keyring reused for public mandated reporters (a refinement of ADR-042 §D9); (c) signer accountability via the PII-free report.signed audit event (no SHINES change), with the signer↔reporter identity match as the enforcement gated on #691; (d) the integrated deferral — integrated accepts unsigned mandated indefinitely; L4 is fully satisfied globally only when the follow-up lands; not a Phase-5 blocker.

  • CHANGELOG.adoc per MR; Standalone Intake — Backend-Profile Architecture (the register→approve→sign flow + the recorded signer + the gate); the program-brief P4 row → Done on the final MR; services.adoc stays a thin index.

  • Tracked follow-ups: (1) the signer↔reporter identity match (#733) — confirm the signature belongs to the person the report claims to be from, by matching the signer against the SHINES-issued unique reporter id. The legacy mandatory-reporting portal keeps an in-house enum of all valid reporter ids (a known finite set, not free-text); the design is to roll that set into the keyring so an existing reporter’s id binds to their key, with brand-new CRAIG-onboarded reporters nesting into the same flow seamlessly. Gated on #691 (the authoritative SHINES contract / the reporter-id list); (2) surface the conformed document on the signed success view (#734) — the #713 debug CpsRequest panel renders only on the unsigned /public path; extending it to the signed/mandated success view (reusing the existing EXPOSE_SSN_PII debug gate) makes the mandated flow observable for testing while the real file-drop transport is deferred; (3) integrated/cases-backed individual signing (#731, relates #723); (4) keyring-receipt hardening (#732, relates #691). #638 (category parity) was already closed (MR !758 — the field is captured into raw_submission); P4.2 adds the edge form <select> + the server-side validation.

Delivery sequence (as delivered)

  1. Plan + ADR MR — committed this plan + the ADR-043 amendment + the program-brief detail; filed the 4 issues
    the follow-ups under epic &60.

  2. One MR per step — P4.1 → P4.2 → P4.3 → P4.4 (last) (full validate + J1–J8; Closes #N + closing comment; flipped the prior step’s Status). MRs: P4.1 !827 (#727), P4.2 !828 (#728), P4.3 !829 (#729), P4.4 !830 (#730).

  3. Final MR — program-brief P4 → Done; archived this plan; Plan Completion Audit (this MR).

Edit this page · latest