ADR-042: Modular Per-Backend Plugin for Standalone Intake (SHINES Alignment)
On this page
Status
Accepted (2026-06-23). Resolves the design question raised while scoping the standalone mandatory-reporting portal + SHINES integration. This ADR fixes the architecture; the code lands as separately-weighted follow-ups (see Implementation scope).
Related: ADR-017 (stateless intake — the edge this layers onto), ADR-018 (partner signer keys + detached JWS — the verification this extends into standalone mode), ADR-036 (state-bundle build-time-TOML / no-toml-leak pattern — borrowed, not consumed), ADR-037 (field ownership — contrasted: worker-role read/write authz, not reporter-driven capture relevance).
Context
craig-intake is a stateless edge (ADR-017): validate + forward, no service-side DB / MQ / AuthzEngine. It serves two channels that share ONE intake DTO and ONE validation path:
-
a human web form (
services/craig-intake/static/report.html, served only in standalone mode), and -
a 3rd-party API (
POST /partner/v1/reports), JWS-signed (ADR-018), for external systems submitting the same report.
Both deserialize craig_intake_contracts::SubmitReportRequest and run the same validation::prepare_submission (services/craig-intake/src/api/validation.rs). It runs in two modes: integrated (forwards to CRAIG’s own craig-cases) and standalone (forwards to an external backend). Today the forwarder is a single concrete CasesForwarderSink hardcoded to craig-cases (services/craig-intake/src/sink/cases_forwarder.rs) — there is no backend abstraction, and standalone mode still points at craig-cases.
The new requirement, and its precise boundary
The standalone portal must be deployable in front of SHINES (Georgia’s legacy monolithic CCWIS) as the durable store. Four constraints define the shape of the solution:
-
Conform to SHINES, today. While connected, SHINES is the system of record, so every SHINES-required field must be faithfully sourced — not best-effort with nulls.
-
Never bind the core. The conformance applies only to standalone intake when connected to a legacy backend. craig-intake’s generic DTO + validation + the integrated/native path stay jurisdiction-neutral — this is just CRAIG’s existing state-neutral platform invariant (the whole point of the Multi-Jurisdiction Foundation, Plans T-Y) applied to intake. No future jurisdiction inherits SHINES’s shape.
-
Disposable by construction. When CRAIG replaces SHINES, the SHINES-specific fields, crosswalk, mapper, and rules must strip cleanly — a bounded, enumerable edit, not an open-ended untangling (the true strip surface is enumerated in the 2026-08-07 amendment; the original "zero residue" phrasing overstated it).
-
Modular fields with declared relationships. Fields must be easy to add/remove, and the design must declare relationships between them — conditional visibility / enablement and required-if rules (e.g. a "badge number" field is relevant only when the reporter is law-enforcement; greyed-out/absent otherwise). This relationship logic must stay consistent across the human form (UI behavior), the 3rd-party API, and server-side validation — a field hidden-as-irrelevant must not be required server-side, and an irrelevant field arriving via the API is handled identically. (CRAIG has already been bitten by form/server divergence: #639, #650.)
Transport + data realities (settled while scoping)
-
SHINES has no intake API today and is monolithic; the API is net-new on the SHINES side. CRAIG defines a reasonable target contract and builds against a mock (extending the existing
craig-mock-server, which already emulates SHINES systems), documenting the contract as a proposed SHINES intake API spec. -
Synchronous, durability-confirming. The sink POSTs and blocks on SHINES’s committed-write confirmation; the returned SHINES record ID becomes the reporter’s tracking code. Because the edge is stateless, that round-trip is the only durability receipt — no optimistic acks, no local buffering (a SHINES outage surfaces to the submitter; absorbing outages is the deferred #215 local-outbox, out of scope here).
-
RefVals crosswalk is net-new. SHINES keys race/marital-status/language off numeric IDs and sex off
M/F; CRAIG uses string enums. The authoritative SHINESRefValscode tables are not yet available, so the crosswalk ships as a swappable placeholder with "load authoritative RefVals before go-live" tracked as a blocking data dependency. -
Signing is a 3rd-party→CRAIG concern only. SHINES has no signature capability; CRAIG→SHINES is a plain authenticated JSON POST. Browser-side keypair generation + detached-JWS verification already exist (ADR-018;
services/craig-web/static/js/craig-sign.js) — but JWS is verified today only in integrated mode (forwarded to craig-cases for verification + replay dedup via cases'seen_jtitable). A SHINES-connected standalone edge has no downstream verifier and no DB. -
No partner tenancy in standalone-SHINES. There are no 3rd-party "partners": anyone may submit via the API provided the detached JWS verifies against a
kidpresent (and active) in a known-key registry. The public form path is unchanged (anonymous, CAPTCHA, IP-rate-limited). -
The only state lives in a sidecar. Since the edge is stateless (ADR-017) yet the registered public keys and the JWS replay-dedup records ARE state, a minimal companion service
craig-intake-keyringowns them — exposing the same HTTP contracts craig-security and craig-cases already expose, so intake’s existingSignerAuthClient/JwsReplayClientpoint at the sidecar (no new client; JWS verified in standalone as in integrated). Its store is an unencrypted flat JSON file: it holds only public keys (keygen is client-side; private keys never leave the browser) + replay records — all publishable, so encryption would be obscurity, not security (Kerckhoffs).
Decision
Adopt a per-backend "backend profile" layered onto standalone mode — the seam, not a framework. This is "Approach A" from the design panel, refined with a typed sidecar.
-
A narrow
IntakeSinktrait. Extract a trait over the submission path (forward+get_report_status);CasesForwarderSinkbecomes one impl and a newShinesSinkthe other. The cases-specific partner-edge BOLA methods (get_report_owner_partner/verify_partner_owns_report) stay on the concrete cases type — they encode craig-cases ownership semantics and are not part of the backend abstraction. The trait references NO SHINES type:forwardtakes aForwardRequestcarrying the typed body + the raw submittedValue, andShinesSinkparses its SHINES views from the rawValueinternally — so deletingbackend/shines/leaves the generic seam compiling. It returns a backend-agnosticTrackingReference(aStringnewtype — SHINES record IDs are legacyNUMBER`s, not UUIDs); the cases backend wraps its UUID. This propagates to the public `ReportConfirmation/ReportStatusand the three SDKs in lockstep (pre-1.0CHANGELOGChanged). -
Typed carriers, never the
Valuebag. Genuinely-new top-level SHINES inputs (the caretaker block, foster-care/military/prior-report flags, the ninequestion*narrative fields) live in typed views — aExtraFieldssidecar (which travels as sibling top-level JSON keys;SubmitReportRequestserde-ignores them and gains ZERO new top-level scalars) and an extendedNarrativeView. Per-person SHINES fields (SSN, child grade/isVictim, adult dob/address/contact, the mother/father designation) extend the EXISTING typedChildEntry/AdultEntryviews — not a parallel struct. This honours "noserde_json::Valuein business logic" and makes the strip-boundary explicit. The report contract is four encodings that move in lockstep: the server views, the Rust SDK (craig-intake-sdk), the Python SDK, and the TypeScript SDK (the #639/#650 drift surface). -
A single declared field-relevance rule table + ONE pure evaluator. Relationships are data:
{ field, relevant_when: Predicate(over already-captured scalars), required_when_relevant: bool }. The same table drives all three surfaces — the human form (served at a standalone-only schema endpoint, an Alpine driver computing show/hide/required, replacing today’s hand-codedx-ifstrings), server-sideprepare_submission, and the 3rd-party API path (an irrelevant field is dropped, a required-when-relevant field that is blank is rejected). A shared typedPredicategrammar (serialized to JSON for the client, matched in Rust on the server) makes UI/server drift structurally impossible — generalizing the existing hand-writtenis_active_duty → military_branchconditional. -
A typed RefVals crosswalk built on the existing
craig-reference::afcarsenum→numeric-code precedent (gender_to_afcars(Gender) → u8and siblings), behind a trait so the placeholder GA map swaps for the authoritative table without touching the mapper. -
A typed mapper CRAIG → SHINES
CPS_Request, reusing the crosswalk for numeric RefVal IDs. -
A boot-time fidelity gate. Because the SHINES dictionary carries no required/optional annotations, the required-field set is encoded in a typed Rust manifest the compiler checks;
boot_standalonefails fast if any SHINES-required field lacks a declared source or any crosswalk enum lacks a mapping — mirroring CRAIG’s pervasive boot tripwires (ADR-034/036/037). Fidelity fails closed. -
A key-registry sidecar + signature-only auth for the SHINES-connected build. The
craig-intake-keyringsidecar (above) owns the known-key registry + the replay-dedup store and re-exposes the craig-security signer-key + craig-cases replay-check contracts, so intake’s existing clients verify JWS in standalone exactly as integrated — keeping the edge stateless. Because there is no partner tenancy, the standalone signed-API path is a distinct handler (signature-verified against the registry, no partner-id binding) with the full reporter-type set; it cannot reuse the partner handler (which bindspartner_idand accepts only the public reporter subset). The cases-bound status/attachment routes are not mounted underShines(SHINES has no status-read contract; the tracking reference is a durable receipt).
Everything backend-specific lives under services/craig-intake/src/backend/shines/, selected by a BackendProfile enum resolved only in boot_standalone. The core DTO’s semantics, the shared validation pipeline’s behavior, and the integrated/native path are unchanged for non-SHINES deployments. Stripping SHINES = flip BackendProfile::Shines → None, delete backend/shines/, and remove the bounded set of profile-gated extension points threaded through the core — the per-person Option sidecar fields DO live on the shared ChildEntry/AdultEntry views (Decision item 2 above is the accurate statement; the original summary’s "never merged into it" contradicted it). The 2026-08-07 amendment enumerates the full strip surface.
Promotion to a generic trait IntakeBackend + manifest + registry is deferred until a second backend (e.g. SACWIS) is real; the shape above is deliberately trait-ready so that promotion is a mechanical refactor, not a rewrite.
Alternatives considered
A design + judge panel (four independent approaches, three independent judges scoring fidelity / modularity / craig-fit / cost / risk) evaluated the space. All three judges and all four approaches' own self-critiques converged that a plugin framework is not justified at one backend.
A. Minimal per-backend profile + typed sidecar (CHOSEN)
Builds only the non-speculative parts (sink trait, typed views, one rule table + evaluator, typed crosswalk, typed mapper, boot gate, the keyring sidecar + signature auth). Full fidelity by compile-time typing; cleanest possible strip; jurisdiction-neutral core preserved; trait-ready for backend #2.
B. Purpose-built IntakeBackendPlugin framework (rejected)
A first-class plugin contract (trait + TOML manifest + build-time codegen) any backend implements. Rejected as premature: it pays framework cost (extra crates, codegen) for exactly one backend, replacing speculative reuse with speculative generality. Its own author conceded the honest minimum is "the seam, not the framework." Adopt B’s shape mechanically when a second backend earns it.
C. Express SHINES as an instance of the Plan T-Y substrate (rejected as primary)
Reuse the composition engine (Plan W/X), field-authz (Plan Y), and the state bundle (Plan U). Rejected: a read-side/write-side category error — Plan W/X compose UI panels/case-sections by role and have no per-field inter-field conditional-visibility representation; Plan Y is worker-role read/write authz on stored rows via a zen-engine AuthzEngine that the stateless edge does not (and per ADR-017 should not) boot; and the craig-state-bundle crate drags craig-exchange-contracts/-transport/-partner-audit into a deliberately lean edge. The craig-exchange SHINES StandardAdapter is pub(crate) + transport-bound inside a stateful service and is not reachable from the edge. What genuinely transfers is patterns (build-time-TOML/no-toml-leak, additive-contribution + fail-fast-on-conflict, env-driven activation) and the afcars crosswalk precedent — which the chosen approach borrows. Borrowing a pattern is not instancing a framework.
D. Runtime-loaded data-only backend descriptor (rejected — unanimous last)
The backend "plugin" as a runtime-loaded descriptor (field schema + rules + crosswalk + mapping) interpreted by a generic engine; add/strip a backend = add/remove a file, no recompile. Rejected: it forfeits the compile-time fidelity guarantee the system-of-record constraint demands (ADR-036 deliberately chose build-time codegen for exactly this reason), then must re-erect it with separately-maintained, lapse-prone lint + fixture gates; it reintroduces serde_json::Value business logic the conventions push against (a mini language + interpreter to validate); and it would import a runtime rule engine (zen) into an edge that today has zero such dependency. Maximum ops-time flexibility bought at the cost of the one guarantee that cannot lapse.
Consequences
Positive
-
Fidelity by construction. A missing SHINES-required field or an unmapped crosswalk enum fails the build or boot, not silently a partial
CPS_Request. -
Clean, bounded strip. SHINES-only logic concentrates under
src/backend/shines/and the typed sidecar, with a small enumerable set of profile-gated extension points in core modules (2026-08-07 amendment); removing SHINES is a directory delete + an enum flip + those bounded edits. The disposability requirement is structural, not aspirational — but it is a multi-file edit, not a one-directory delete. -
Jurisdiction-neutral core preserved in behavior. SHINES fields ride the shared
ChildEntry/AdultEntryviews asOptionsidecar extensions and profile-gated arms — absent/inert for every non-SHINES deployment; no SHINES RULE changes shared validation semantics for other backends. -
Fixes a known bug class. The single declared rule table consumed by both UI and server removes the form/server-divergence failure mode (#639, #650) by construction.
-
No premature framework. N=1 stays small; the trait-ready shape makes backend #2 a mechanical promotion.
Negative / residual risk
-
Per-backend work is a code change behind a build, not a config edit. Adding/stripping a backend or a field recompiles. For a stateless public edge with
include_str!assets, a strict CSP, and no config-reload story, paying a recompile to buy guaranteed fidelity is the deliberate trade (vs. Approach D). -
The shared
Predicategrammar must stay small (equality / presence / boolean over already-captured scalars). Rich cross-field arithmetic would push toward a real rule engine — explicitly out of scope; a future need is a separate, tracked decision. -
A new sidecar service to operate (
craig-intake-keyring) for the SHINES-connected build. This is the deliberate cost of keeping the edge stateless: the only state (public-key registry + replay dedup) is isolated in one tiny flat-file service rather than smeared into the edge. Replay dedup is keyed byjti(the unchanged replay client carries nokid;jtiis a per-submission UUID, so cross-holder collision is negligible). -
The SHINES API and RefVals are mocked/placeholder until the real contract and code tables arrive — both tracked as blocking go-live dependencies, not silent gaps.
Implementation scope (follow-up issues, not this ADR)
This ADR is the decision; the code lands as separately-weighted follow-ups, sequenced in the plan (Standalone Intake Backend Plugin) and tracked under its epic. The sink-trait extraction lands first (pure refactor, de-risks); the typed views + crosswalk + mapper + boot gate + rule engine + the craig-intake-keyring sidecar + signature auth + the SHINES mock + e2e follow. Implemented under epic &58 (MR1–MR10, #681–#690); the SHINES API contract, RefVals, and required-field set remain external go-live dependencies (#691–#693).
Related decisions
-
ADR-017 — the stateless edge this layers onto; the
BackendProfileselector lives only inboot_standalone. -
ADR-018 — partner signer keys + detached JWS; this ADR re-exposes that signer-key + replay-check contract from the
craig-intake-keyringsidecar so the stateless edge verifies JWS in standalone-SHINES (no partner tenancy). -
ADR-036 — the build-time-TOML/no-toml-leak typing discipline this borrows (pattern, not crate).
-
ADR-037 — field ownership; contrasted (worker-role authz, not reporter-driven capture relevance).
Amendments
Amendment 2026-06-28 — SHINES adult-field forwarding decisions (#743)
Epic &61 Step 5 (#743) closed a family of SHINES adult-field defects surfaced by the 2026-06-28 craig-intake coverage audit. Two of the fixes are forwarding-contract decisions — what the SHINES profile collects versus what it forwards — recorded here as the as-built.
A1. maltreater_relationship_to_victim — collected under SHINES, NOT forwarded to the CpsRequest wire
When an adult is flagged is_alleged_maltreater, validate_adult_entry requires maltreater_relationship_to_victim
(profile-agnostically). The SHINES public form now renders that selector (revealed by the checkbox) and
SHINES_ADULT_KEYS carries it, so the alleged-maltreater path is satisfiable under SHINES — previously it was an
unsatisfiable 400 (no field existed to provide the required value).
The value is not mapped onto the SHINES CpsRequest: PersonInfo/OtherHouseholdInfo (mapper.rs) carry only
is_alleged_maltreator with no relationship target, and adding one to the SHINES wire is gated on the authoritative
SHINES contract (#691, a go-live dependency; RefVals are the separate #692). So the field reaches cases/audit (the captured submission)
but not the SHINES forward, until #691 adds a wire field. The asymmetry is deliberate: form completeness + server
validation are satisfied without inventing an unauthoritative wire field.
A2. relationship_to_child — no longer collected under SHINES
The per-adult relationship_to_child has no SHINES target — SHINES models the household via
rel_to_primary_caretaker, and AdultEntry carries no relationship_to_child field — so a SHINES payload that
included it was silently dropped by serde before mapping (entered data lost). It is now gated out of the SHINES form
and removed from SHINES_ADULT_KEYS; it remains collected + forwarded under None/cases (persisted in
raw_submission). 750’s forthcoming [serde(deny_unknown_fields)] will make any future such drop loud rather than
silent.
superseded in part by the 2026-06-28 (#750) amendment below — AdultEntry now carries a
relationship_to_child field (modeled so the None/cases body it sends is not 400’d once unknown keys are denied). The
SHINES behavior is unchanged: it is still not sent under the SHINES profile (not in SHINES_ADULT_KEYS), so there is
no SHINES silent drop.
|
Amendment 2026-06-28 — input-integrity hardening (#750)
Epic &61 Step 4 (#750) closed the input-integrity gaps from the same coverage audit. Three decisions are forwarding/validation-contract as-built.
B1. Unknown-field rejection is per-entry serde deny PLUS a top-level key-set diff — never a blanket deny
The submission body is multi-owner: the core SubmitReportRequest fields plus, under the SHINES profile, the 14
SHINES-only ExtraFields keys (§D3), which travel as additional top-level keys in the same body. A blanket
[serde(deny_unknown_fields)] on the core contract would therefore 400 every valid SHINES submission (and every
SDK body using the [serde(flatten)] extra shape). Two mechanisms instead:
-
Per-entry:
#[serde(deny_unknown_fields)]on the typedAdultEntry/ChildEntryviews, so a misspelled per-person key (lost data on an abuse report) is a loud 400 via the existing per-entryfrom_value. -
Top-level: an explicit key-set diff in
prepare_submission(all three channels). The allowlist is the core key set — derived fromserde_json::to_value(body)(the contract has noskip_serializing_if, so the derived set is complete and cannot drift) — unioned, only under the SHINES profile, withSHINES_EXTRA_KEYS. The core crate stays SHINES-agnostic:SHINES_EXTRA_KEYSlives inbackend/shines/extra.rs(the "strip cleanly" guarantee) and a drift test pins it to the struct + the cross-language fixture. A#[serde(flatten)]catch-all was rejected: utoipa renders it asadditionalProperties: true, advertising the opposite of the intent in the published OpenAPI.
This supersedes the pre-1.0 behavior where a None/cases submission silently tolerated SHINES-shaped top-level keys (the SHINES union is gated to the Shines profile). Under None a SHINES key is now an unknown field → 400: a None deployment has no SHINES backend and never parses those keys, so accepting-and-dropping them was the same silent data-loss this hardening removes (no fig leaf; pre-1.0, so no compat carve-out). SHINES keys are accepted only under the Shines profile, where they are actually parsed.
B2. AdultEntry/ChildEntry widened to the full None-form + SDK key set
To make the per-entry deny safe, every key the public form and the SDK send is now modeled: AdultEntry gains
relationship_to_child (supersedes A2’s "no such field" note — captured under None/cases, still not sent under
SHINES), phone, email, county, is_primary_caregiver, dob_approximate; ChildEntry gains dob_approximate
and age. age is capture-only: the SHINES chindInfo[] mapper has no age slot (DOB is canonical), so it is
forwarded raw, never mapped; it is range-validated (0..=21). The form’s x-model.number age input clears to JSON
null (→ None), so a blank age is "not provided", not a 400.
B3. admin_unit allowlist is jurisdiction-parameterized, enforced on every channel
When the deployment’s configured jurisdiction (settings.jurisdiction) resolves to a US state/territory with an
AdminUnit table, the routing admin_unit is allowlist-validated against that jurisdiction on all three submit
channels (public, partner, signed — no partner exemption). For a tribal/territory key that resolves to no table the
allowlist is skipped and only the NAME_MAX cap bounds the value (Plan-S neutrality — the allowlist never defaults to
a single state). reporter_county is deliberately not allowlisted: a reporter may live out-of-jurisdiction. The
resolved jurisdiction is layered as a ConfiguredJurisdiction extension on each submit nest.
B4. The keyring validates the registered JWK at registration (MR-2)
craig-intake-keyring’s `POST /keys/register (§D8) now structurally validates public_key_jwk as a P-256 public key
(p256::PublicKey::from_jwk_str) before persisting. ES256 is the only signer algorithm, so a P-256 parse is the exact
check — it mirrors craig-intake api/jws.rs. A structurally-invalid key previously reached the flat-file store and
surfaced only as a verifier failure at lookup; it is now a fixed 400 at registration (the rejected key material is
never echoed). A proptest proves arbitrary JWK JSON never panics. The sibling craig-security register is tracked
for the same parity hardening as a follow-up. The other MR-2 fixes — char-based concern_description min-length, loud
(map_err) forward serialization on the public + partner paths, craig_store::sanitize_filename on attachment
uploads, and the kiosk "Submit Another" no-residue invariant — are intake-internal hardening recorded in the
CHANGELOG, not contract changes.
Amendment 2026-07-01 — SHINES signed-path attachments via a signed hash manifest (epic &65)
The standalone-SHINES signed path (§D9) previously dropped attachments — the CpsRequest had no documents field and
no attachment route was mounted under the SHINES profile, so a mandated reporter could not send the supporting
documents the legacy SHINES portal accepts. Epic &65 (#940–#946) added attachment support to the signed/mandated path.
The decision + as-built:
-
Signed hash manifest, not signed transport. The reporter’s browser SHA-256-hashes each file, adds a
supporting_documentsmanifest ([{fileName, fileType, sha256}]) to the report JSON, and the existing detached JWS covers that manifest. Documents are therefore tamper-evident without signing the upload transport.Sha256Hexis a construction-validated newtype in the backend-agnosticcraig-validationcrate, so theIntakeSinkseam and the SHINESSupportingDocumentshare one type without the trait referencing a backend type. -
Edge-authoritative verification (§D9).
craig-intakecontent-negotiates the signed submit: JSON-only (SDK clients) or onemultipart/form-datarequest (areportpart = the exact signed JSON + onefilepart per attachment). It verifies the JWS over the RAWreportbytes (the canonical-JSON signing contract is UNCHANGED — the raw bytes are never reparsed/reserialized before verifying), recomputes each delivered file’s SHA-256, and matches the delivered set against the signed manifest by multiset (extra / missing / swapped file → 400,ATTACHMENT_MANIFEST_MISMATCH). The signedfileNameissanitize_filename-neutralized before use. Verification is atomic: any failure rejects with ZERO forwards to SHINES. Errors name no filename or hash (no content oracle); filenames/hashes/bytes are never logged. -
Sink → SHINES is no longer JSON-only (§D7). When ≥1 attachment is verified,
ShinesSink::forwardPOSTs onemultipart/form-databody — arequestpart (theCpsRequestJSON, now carryingsupportingDocuments) + onefilepart each — sharing the same redaction +accepted-status handling as the JSON arm; the JSON-only POST is unchanged for the no-attachment case. Exactly one POST either way (no retry loop). See the proposed SHINES API. -
Caps.
MAX_ATTACHMENTS(compile-time, 5) bounds the manifest;CRAIG_INTAKESIGNED_MAX_FILE_BYTES(10 MiB default) is the per-file cap enforced while buffering each part;CRAIG_INTAKESIGNED_BODY_LIMIT(30 MiB default) is the whole-request cap layered as an innerDefaultBodyLimiton the/signednest, overriding the globalbody_limitfor those routes. -
Debug output. Under the existing
EXPOSE_SSN_PIIack flag the signed response is aSerialize-only superset (never in the OpenAPI/SDK contract) carrying the conformedCpsRequest+ the edge-recomputed hashes + the signed manifest, servedCache-Control: no-store, never logged, absent when the flag is off. -
No new BOLA surface. Attachments ride the atomic signed submit; there is deliberately no follow-up
GET/POST /{id}/attachmentscapability under SHINES (legacy record ids are guessable). -
Clients. The browser report form (secure context) and all three SDKs (Python/TS/Rust) hash → manifest → sign → one multipart submit; a cross-language
attachment-hash.jsonparity vector pins that every hasher agrees with the edge’s recompute.
Out of scope (unchanged): unsigned concerned-citizen SHINES attachments (no signature to anchor a manifest); the cases-backed attachment proxy (its own route); SHINES-side re-verification (permitted, not required).
Amendment 2026-08-07 — honest strip-surface enumeration (#999)
The original Decision summary and Consequences overstated disposability
("delete one directory", "zero residue", "no SHINES field … touches the
shared validation") and contradicted Decision item 2, which correctly says
the per-person SHINES fields extend the EXISTING typed
ChildEntry/AdultEntry views. The layering is sound — Option sidecar
fields and profile-gated arms, exactly the typed-sidecar approach chosen —
but the strip is a bounded multi-file edit, and this amendment is its
enumeration so the true surface stays discoverable (verified against the
tree, 2026-08-07):
-
backend/shines/— the directory delete, plus theShinesvariant inbackend/mod.rs. -
api/validation.rs— theshines_household_role: Option<ShinesHouseholdRole>field on the sharedAdultEntryview (with the SSN/dob/relationship sidecar fields) and the profile-gatedExtraFields/SHINES_EXTRA_KEYSallowlist extension. -
config.rs—shines_url/keyring_url/debug_emit_cps_requestfields, theirvalidate()arms, and the unwrap helpers. -
main.rs— SHINES boot dispatch,check_boot_fidelity(),ShinesSink::new, the signed-submit wiring. -
relevance/mod.rs—shines_field_rules()+ the profile-gated extend. -
api/public.rs— the profile-gatedExtraFieldsparse
merge_shines_extra(). -
api/partner.rs— theExtraFields::default()construction. -
api/mod.rs— the mountedbackend::shines::signed_submitOpenAPI paths/components. -
crates/craig-reference— theShinesHouseholdRoleenum (shared crate, outside craig-intake entirely).
A bare directory delete does not compile: use crate::backend::shines::…
appears in six core modules. None of this changes the decision — the
sidecar shape was chosen precisely so these touch-points are Option-inert
for every non-SHINES deployment — only the prose claiming otherwise.