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
mandatedreporter with no proof (services/craig-intake/src/api/validation.rsPUBLIC_REPORTER_TYPES). -
Signing must be accountable, not just verified. The existing
/signed/v1/reportsverifies a JWS then throws the evidence away (backend/shines/signed_submit.rsforwards 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
Signedbackend). This closes the bypass on SHINES deployments; integrated/cases-backed keeps accepting unsignedmandateduntil 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.signerdecision); (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) — theIntakeSinkabstraction 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:
-
Stop discarding the verification — record who signed (a PII-free
report.signedaudit event); the actual non-repudiation value. (P4.1, backend) -
A browser signer matching the SDK header (
alg,kid,jti,iat), atype=modulebridge, the mandated form-branch, a required category, and a kid-bearing key file. (P4.2) -
Approval-status UX driven off the per-kid active lookup + the submit-time 401. (P4.3)
-
A structural server gate:
Signed⇒/publicrejectsmandated. (P4.4, last)
No new endpoint, key store, or crypto primitive.
Status
| Step | Description | Status |
|---|---|---|
P4.1 signer-record |
|
Done (2026-06-27) — #727 |
P4.2 signing-UI |
SDK-contract browser signer + bridge; mandated branch → |
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: |
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)
-
Reuse the SHINES
/signed+ keyring — no new handler or key store. -
Accountability via a recorded signer. P4.1 captures the
JwsVerification(instead of discarding it) and emits a structuredreport.signedaudit 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 thekidin the keyring. Nothing new ships to SHINES (existing report fields only), so there is noForwardRequest.signerplumbing. 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 proposedreporterInfoschema does not yet carry). -
The browser signer matches the SDK, not craig-web. Intake’s verifier requires
jti+iatin the protected header; the SDK builds{alg,kid,jti,iat}. Port craig-web’scanonicalize+loadPrivateKey, but write the signer to the SDK header (browser-generatedjtiUUID + epochiat). -
The gate is structural, not a config knob — derived
matches!(config.backend, Some(Signed(_))). You cannot be rejected from/publicfor a channel that does not exist. -
Client branches on the existing signal
$store.relevance.backend_profile === 'shines'(as P3.5 did) — no newUiConfigfield. -
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. -
Integrated signing is deferred (documented posture): integrated accepts unsigned
mandateduntil 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.
-
Capture, don’t discard.
verify_signed_jwsreturns aVerifiedSigner(theJwsVerification+ the canonical-payload SHA-256 viacraig-signing::hash_payload+ the detached-JWS signature segment) to the handler, instead of dropping the verification. -
Emit a PII-free
report.signedaudit 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 thekidin the keyring, so the record is PII-free by construction while still attributable. Re-verifiable later against the report in SHINES + the public key. -
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.
-
Port the reusable helpers
canonicalize+loadPrivateKeyfrom craig-web into the edgecraig-sign.js. -
Write
signReportto the SDK contract — header{alg:'ES256', kid, jti, iat}wherejtiis a browser-generated UUID andiatis epoch seconds; detached JWS over the canonicalized body. Add a cross-language parity vector (browsercanonicalize==craig-signing). -
Add
report-sign.js(type=module): import the helpers, expose a frozenwindow.CRAIG_SIGN.signReport. Serve it +craig-sign.json/reportunder the SHINES signing gate inui.rs(today they mount only on/keygen). Signing fires on user submit, so the deferred module is always ready (keeps the Alpine load order). -
Branch the submit in
report-form.js: whenreporter_type === 'mandated'ANDbackend_profile === 'shines', sign andPOST(method,headers: { content-type, x-jws-signature },body= the exact signed bytes). Otherwise keep the current/public/v1/reportspath. Minimal error message on non-200 (P4.3 enriches). -
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 newVALID_MANDATED_CATEGORIESallowlist on every channel (invalidate_top_level_enums, consistent with howreporter_type/concern_typeare 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 intoraw_submission(#638 — already closed, MR !758); P4.2 adds the edge form<select>+ the validation. -
i18n —
intake-sign-*keys incrates/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.
-
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/keyslist (enable_key_listis default-off and lists all keys — a view aid, not per-reporter status). -
Graceful failure — no key → "register first" (→
/keygen); not-active → "awaiting operator approval — check back" (→/keysif enabled); the submit-time 401 is caught and mapped to the same guidance (uniform server 401, no oracle). -
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. -
i18n —
intake-sign-status-*keys inpublic.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)
|
P4.4 — Server-side gate (runs last)
Goal: close the bypass under SHINES — defense-in-depth behind the UI branch.
-
Derive
mandated_signing_required = matches!(&backend, Some(BackendWiring::Signed(_)))atbuild_routerentry — by reference, so the latermatch backendstill consumes it. -
Layer
Extension(MandatedSigningRequired(bool))on the public nest (alongside the existingDebugEmitCps/BackendProfileextensions);submit_reportreads it. -
Reject via a standalone
validation::reject_unsigned_mandated_on_public(&body, required)check (mirroring the existingrequire_mandated_categorypattern — not threaded intovalidate_reporter_type): whenrequiredand the reporter ismandated, 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 400validate_reporter_typeraises for a malformed enum, so the two stay single-purpose andPUBLIC_REPORTER_TYPESis left untouched (mandatedis still valid on/publicoff SHINES). The check runs insubmit_reportright after the honeypot, before CAPTCHA/validation, so a gated request fails fast. Add the 422 to thesubmit_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
|
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
Signedwiring); (b) the keyring reused for public mandated reporters (a refinement of ADR-042 §D9); (c) signer accountability via the PII-freereport.signedaudit event (no SHINES change), with the signer↔reporter identity match as the enforcement gated on #691; (d) the integrated deferral — integrated accepts unsignedmandatedindefinitely; L4 is fully satisfied globally only when the follow-up lands; not a Phase-5 blocker. -
CHANGELOG.adocper 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.adocstays 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
CpsRequestpanel renders only on the unsigned/publicpath; extending it to the signed/mandated success view (reusing the existingEXPOSE_SSN_PIIdebug 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 intoraw_submission); P4.2 adds the edge form<select>+ the server-side validation.
Delivery sequence (as delivered)
-
Plan + ADR MR — committed this plan + the ADR-043 amendment + the program-brief detail; filed the 4 issues
the follow-ups under epic &60. -
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). -
Final MR — program-brief P4 → Done; archived this plan; Plan Completion Audit (this MR).