Shared Crates — Public API Surface
On this page
This is the canonical reference for the shared crates' public APIs. Per-service endpoint/table/event
detail lives in the API reference + the data-model-* pages; this page covers
the reusable library crates under crates/.
craig-common
-
ApiErrorenum — RFC 9457 Problem Details: NotFound, BadRequest, Unauthorized, Forbidden, Conflict, Internal, Validation, plus typed-source variants (Db / ObjectStore / Http / Csv / Uuid / Header). See Architecture § RFC 9457 Problem Details for the catalogue rationale +coding-conventions.md § RFC 9457 problem-type URLs (#311)for call-site discipline.-
Implements
IntoResponse(Axum) andFrom<anyhow::Error>(internal anyhow paths). New code should prefer typedthiserrorerrors + aFrom<TypedError> for ApiErrorimpl over routing throughanyhow. -
BadRequest+Conflictcarry atype_url: Option<&'static str>populated bybad_request_typed(url, detail, field)/conflict_typed(entity, detail, url);problem_type_url(&self)returns the variant’s URL. Catalogue:craig_common::error::problem_types(the module is the source of truth; counts drift as endpoints add typed refusals).
-
-
Concurrency primitives (canonical per
coding-conventions.md § Style):parking_lot::Mutexfor short sync critical sections (no poisoning);tokio::sync::Mutexwhen the lock crosses.await;std::sync::Mutex/RwLockare forbidden in CRAIG-authored code (preferparking_lot). -
Id=uuid::Uuid;new_id()generates UUID v7. -
PageRequest { page: u32, per_page: u32 }—offset()/limit()(clamped toMAX_PER_PAGE = 500); derivesDeserialize+IntoParams.PageResponse<T> { data: Vec<T>, page, per_page, total: i64 }— derivesSerialize+ToSchema. -
ServiceSettings— loaded fromCRAIG_<PREFIX>__*env vars. Fields:port,database_url,rabbitmq_url,oidc_issuer,oidc_internal_url(opt),log_level,jurisdiction,cors_origins,body_limit,db_max_connections,db_idle_timeout_secs.load(prefix);Debugredactsdatabase_url+rabbitmq_url. Defaults: log_level=info, jurisdiction=georgia, cors_origins=""(same-origin), body_limit=2 MiB, max_connections=10, idle_timeout=600 s. -
supervisormodule (#1229 — moved from craig-api, which re-exports every name): the ADR-061 worker supervision + liveness registry (Supervisor,WorkerHealth,shutdown_signal, theWorkerCheckEntryhealthz wire mapper). Lives HERE so the DB/MQ-less craig-web BFF can supervise its workers without sqlx/lapin; panic text comes fromcraig_mq_error::panic_message_bounded(same layering rationale). See the craig-api section for the full API sketch. -
telemetry::init(default_level)— structured JSON logging viatracing-subscriber. -
rate_limit::RetainingIpLimiter(#1136) — per-IP keyedgovernorlimiter with amortized key retention: every 4096thallow()sweeps buckets indistinguishable from fresh (retain_recent), bounding the key map by distinct IPs per bucket-drain horizon instead of process lifetime.new(Quota)/with_retain_every(Quota, NonZeroU64)(test seam) /allow(&IpAddr) → bool/key_count(); re-exportsgovernor::Quota. Consumed by craig-api’s per-minute limiter and craig-intake’s per-hour public/signed limiters (their quota construction + 429 middleware stay crate-local). -
SubsidyReviewSweepSettings(#1096) — the sweep scheduler’s consent-knob group onServiceSettings(subsidy_review_sweep, nests asCRAIG_FINANCIALSUBSIDY_REVIEW_SWEEP*; all default off).metrics::subsidy_review_sweep— run/leg counters, backlog gauges, duration histogram, last-success timestamp, lease skips (otel + stub twins).problem_typesgainsSWEEP_ALREADY_RUNNING+SWEEP_RUN_STATE. -
SubsidyErrSettings(#1069 M2) /SubsidySgSettings(#1070, ADR-056) — the enrollment consent knobs onServiceSettings(fieldssubsidy_err/subsidy_sg, nesting asCRAIG_FINANCIALSUBSIDY_ERR*/CRAIG_FINANCIALSUBSIDY_SG*; both{ enabled: bool }, default OFF — enabling is the operator’s recorded consent to the ⁂ #1073 money-policy readings). ONEsubsidy_sgknob covers the sg AND nrsg family and gates the create arms
the witnessed activation ONLY — existing agreements' lifecycle (generator, ALL transitions incl. the correctiveguardianship_finalized, reviews, sweep) is deliberately grandfathered.problem_typesgainsCLOSED_PROGRAM(#1070 — closed-cohort creation refusals naming the closure date + the #1071 import path). -
SubsidyImportSettings(#1071, ADR-057 D12) — the conversion-import gate onServiceSettings(fieldsubsidy_import, nesting asCRAIG_FINANCIALSUBSIDY_IMPORTENABLED;{ enabled: bool }, default OFF — flipped only for the conversion window). Gates every import WRITE (create-batch, stage, abort, finalize); the GETs stay readable knob-off for post-conversion audit, and materialized agreements' lifecycle never consults it. -
BusinessClock(#1092, ADR-053 amendment) — jurisdiction → business calendar date.for_jurisdiction(&str)(total:georgia→America/New_York, unmapped → UTC),today(),date_of(DateTime<Utc>),timezone_name().Copy; resolvetoday()ONCE per unit of work and thread theNaiveDatethrough domain logic (the financial store writers take it as a parameter).
craig-auth
-
Claims { sub, preferred_username, email, realm_access: RealmAccess, aud, azp, nonce, at_hash, typ };has_role(role) → bool.nonce/at_hashare OIDC id-token-only claims (#813);JwksProviderverifiestypagainst a configurable expected value (default"Bearer"; the craig-web id-token verifier sets"ID"viawith_token_type). -
RealmAccess { roles: Vec<String> }.
craig-authz (ADR-023 / ADR-024)
-
AuthzEnginetrait (async) —check(claims, resource, action) → Result<(), ApiError>;auto_scope_list(claims, resource_type, action, jurisdiction) → Result<ListScope, ApiError>;check_typed/auto_scope_list_typed(#786) — provided typed seams surfacingAuthzErrorso craig-rules' bootstrap fallback can distinguish source-attestedPolicyMissing(the ONLY admitting class) from a real deny or a runtime fault (incl. the #786PolicyLoadrefresh-fault variant → 500); the defaults report an opaque non-admitting deny (test doubles inherit unchanged), and the typed list seam surfaces a miss asErr(PolicyMissing)where the untyped path keepsOk(ListScope::Denied);resolve_field_permission(claims, resource, field, field_owner) → Result<FieldPermission, ApiError>(ADR-037 §2/§3; default-deny default impl so test doubles / alternate engines compile unchanged) — the acting worker’s effective per-field permission, resolved through the SAME{jurisdiction}-authz-<resource>ruleset (ZenAuthzEnginebuilds the input viabuild_check_input(…, Action::Update)+ injectsfield/field_ownerintoresource.attrs) read through a NEW disjointfield_permissionoutput token, socheck/auto_scope_listare untouched. -
ZenAuthzEngine— concrete impl overzen_engine::Decision+ a dedicated!Send-future eval thread (mirrors craig-rules).boot(source, jurisdiction, source_service, policy_ttl) → (engine, Option<CoverageWarning>)bulk-fetches{jurisdiction}-authz-*rulesets, compiles, primes the cache, computesResourceType × Jurisdictioncoverage.boot_degraded(…)(#786) skips the bulk fetch — empty cache, the REAL source retained — so a degraded boot genuinely self-heals (per-request lazy loads + invalidation events + the bounded post-boot warmup; the TTL task only re-fetches names already cached).refresh_entry(name)/refresh_stale_entries(). -
ResourceRef<'a> { resource_type, resource_id, assigned_worker_sub, supervisor_sub, jurisdiction, attrs: HashMap<&str, Value> }. -
Actionenum —Read | List | Create | Update | Delete | Approve(snake_case). -
ResourceTypeenum — one variant per protected resource across every service (snake_case,IntoEnumIteratorfor the boot coverage check; the enum incraig-authz/src/types.rsis the source of truth). -
ListScope—All | AssignedWorker(Uuid) | AssignedSupervisor(Uuid) | Custom(Value) | Denied. -
FieldPermission—Read | Write | Propose | None(ADR-037 §3; the per-field analogue ofListScope;Propose= a non-owner’s effective permission on a shared field underAction::Update). -
CoverageWarning::Missing { jurisdiction, missing }— boot signal; bails whenCRAIG_<SVC>__AUTHZ_REQUIRE_FULL_COVERAGE=true. -
RulesetSourcetrait +InMemoryRulesetSource(tests) +craig_rules_client::RulesClient(prod).RulesetCache=HashMap<name, CachedRuleset>behindtokio::sync::RwLock.compute_coverage(cache, jurisdiction);ruleset_name_for(jurisdiction, resource_type)="{jurisdiction}-authz-{resource_type}". -
audit::AuthzAuditSink— emission port the engine REQUIRES at construction (ADR-050 / #908); production adapter iscraig_bootstrap::OutboxAuditSink(one-shot outbox tx, fire-and-forget,warn!-only failures).NoopAuditSink(explicit discard) +RecordingAuditSink(test assertions) ship alongside. The engine stagesauthz.cache_misson every source-attested miss (attributed:sub/is_service/service_id; never onPolicyLoadfaults) andauthz.cache_refreshedon every refresh path (trigger-attributed, incl.boot/warmup/lazy_load; eviction =new_version: null). -
audit::build_*_event(…)—authz.access_denied/authz.cache_miss/authz.cache_refreshedenvelope builders. All three are engine-emitted through the injected sink;access_denied(since #1063) is volume-gated by the full-dimension deny aggregate — exact repeats per 60 s window suppress WITH reported counts (suppressed_prior/kind: "suppression-flush"), never silently (ADR-050 §Amendment #1063). -
Fail-closed (#786 fault/absent split): a SOURCE-ATTESTED miss (the source itself returned
None) →checkForbidden (PolicyMissing),auto_scope_listListScope::Denied(untyped path; the typed seam surfacesErr(PolicyMissing)),resolve_field_permissionFieldPermission::None; a refresh FAULT during a miss (fetch/compile failure) →PolicyLoad→ Internal on every path — the engine never claims absence it cannot attest; malformed output → Internal. -
invalidation::spawn_invalidation_subscriber(…)— bindsruleset.changed.<prefix>*to an exclusive auto-delete queue.invalidation::spawn_ttl_refresh_task(engine)— TTL/10 sweep (floor 30 s) with ±25% jitter.
craig-rules-client (Plan A § D4)
-
RulesClient::new(http, base_url, bearer)— clone-friendly (Arc inner) HTTP client for craig-rules.list_by_prefix(prefix) → Vec<FetchedRuleset>(paginates internally);get_by_name(name) → Option<FetchedRuleset>(404 → None). Implementscraig_authz::RulesetSource. -
JwksProvider— JWKS cache with auto-refresh:new(issuer)/with_fetch_url(issuer, fetch_url)(split URLs for Docker);refresh()/start_refresh_task()(hourly);validate_token(token) → Result<Claims>. -
AuthLayer— Axum middleware (new(provider), insertsClaimsinto extensions);auth_middleware()Tower fn;require_role(claims, role) → Result<(), Response>.
craig-db
-
DbPool— wrapssqlx::PgPool,Deref<Target = PgPool>.connect(url)/connect_with(url, opts)/connect_for_gate(url, opts)(#1340 — the ADR-063 migration gate’s variant, 60 s acquire);inner() → &PgPool;begin() → Result<Transaction>;health_check();run_migrations(migrator)(gate-plane only since ADR-063 — the validate isolation lint pins its callers to craig-db + the migrate-mode helper; serving boot verifies viaverify_schema, below);snapshot() → PoolSnapshot;probe_acquire() → ProbeSample. -
DbPoolOpts { max_connections: u32, idle_timeout: Duration, statement_timeout_ms: u64 }. -
Pool observability (#1160):
PoolSnapshot { max, size, idle }(cheap atomic reads for the scrape-timedb_pool_connections_*gauges) andProbeSample { wait, error }fromprobe_acquire— one timed acquire-and-release;PoolTimedOutis the session-exhaustion signal, catching the server-ceiling case (size < maxwhile Postgres refuses new sessions) the gauges alone cannot. The shared bootstrap (craig_api::connect_database) registers the gauge source and spawns the 15 s probe for every service. The serving pools' 5 sacquire_timeoutremains a FIXED constant by recorded policy — the fail-fast cliff is intended; capacity and the instruments are the remedy (deployment guide § Database connection budget). ONE recorded exception (#1340):connect_for_gategives the one-shot migration gate a 60 s bound — a deploy phase waits out a busy server instead of failing on the serving cliff. -
Migration session isolation (#1153):
run_migrationsexecutes on a dedicated connection (detached from pool accounting, closed after the run) — never directly on the serving pool. sqlx performs no session reset on return-to-pool and the F-019after_connecttimeout hook fires only on new physical connections, so a migration’s plain session-scopedSET(e.g.SET statement_timeout = 0) would otherwise serve traffic for up tomax_lifetime. Convention for migration authors: useSET LOCALfor per-migration overrides so one migration’s setting cannot bleed into later migrations in the same run (cargo xtask validate-migration-constraintsreports plainSET statement_timeoutsites). -
Schema state machine (ADR-063, #1306,
schema_statemodule): the ONE classifier both process modes consume. Pure coreclassify(&[AppliedRecord], &[EmbeddedMigration]) → SchemaState(Dirty | ChecksumMismatch | Diverged | Behind | Ahead | Exact, deterministic precedence;Behind ⇒ applied ⊊ embeddedby construction)
embedded_up_migrations(&Migrator)(down-files filtered, matching sqlx apply semantics). Consumers onDbPool:verify_schema(migrator, FloorCheck, gate_hint) → Result<SchemaVerified, SchemaVerifyError>(serving boot: plain-SELECT inspection, no DDL, no advisory lock, SELECT-only-role safe;Aheadtolerated only while theschema_compat_floortable’smin_required_version ≤ max(embedded)) andapply_by_verdict(migrator) → Result<ApplyOutcome, SchemaApplyError>(migration gate:Exact/Aheadno-op,Behindapplies viarun_migrations, everything else refuses). Only SQLSTATE42P01maps to Behind-from-zero; every other DB error passes through typed. Theschema_compat_floorsingleton table exists in all 8 service schemas; destructive contract migrations must bump it in the same file (lint from M2/#1307).
craig-mq
-
EVENTS_EXCHANGE = "craig.events"(topic exchange).connect(url) → Result<Connection>. -
EventEnvelope { id: Uuid, timestamp, source_service, event_type, payload: Value }—new(source_service, event_type, payload)(UUID v7 + now). -
Publisher(Clone) —new(channel),publish(envelope)(routing key = event_type; persistent). -
Subscriber—new(url);subscribe(queue, routing_keys, handler)(competing consumers, durable shared queue — actually durable since #1127/#1104);subscribe_exclusive(queue, routing_keys, handler)(fan-out, exclusive auto-delete). Every subscription runs under a supervisor owning its own connection: on stream loss it reconnects with capped backoff (0.5s→30s), redeclares exchanges/queue/bindings, and resumes — a broker restart no longer silently halts consumption until a process restart. Every session establishment is bounded byAMQP_LIVENESS_TIMEOUT(a compiled 15s constant, deliberately not a config knob — #1528): lapin has no connect/handshake/RPC timeout, so an unbounded rebuild against a broker path that accepts TCP and never answers used to pin the supervisor permanently and stop the backoff ladder dead (ADR-003 § Amendment — #1528, which also records the measured thread-strand cost). Channels setbasic_qosprefetch 16. Redelivery: nack-requeue on 1st failure, requeue=false on 2nd (DLQ); the inbox retry re-runs immediately (#1127 — no in-loop sleep; pacing is the broker’s, ladder redesign is #1053).readyz’s MQ flag reflects the publisher connection; subscriber supervisors heal independently. Handler PANICS are contained on every path (#1203): a per-delivery task boundary converts unwind panics to the normal failure ladder (events → 2-strike nack; DLQ → immediate `TransientPark, never re-invoked in-session; inbox →error_count/last_errorstamp
InboxError::HandlerPanicked), counted bydlq_handler_panics_total{path}. Unwind-only — the panic hook still runs,panic = "abort"is not contained, and handler-owned shared state must remain valid after unwind (the caller’s contract).Fut: 'staticon all subscribe/inbox handler bounds (pre-1.0 breaking, #1203). Every queue is declaredx-queue-type=classic(#1198, ADR-003 §Amendment #1198 — the redelivery semantics above assume classic-no-delivery-limit): a conflicting pre-existing queue fails boot with 406; a mid-life type flip logs aterror!("operator action required") while the supervisor keeps retrying. -
stage_event(tx, envelope) → Result<(), StageError>— transactional-outbox staging (ADR-022 §D3.1): serialize +INSERTthe envelope in the caller’s tx. Returns the typedcraig_mq_error::StageError(re-exported ascraig_mq::StageError); see craig-mq-error. -
subscribe_dlq(queue, routing_keys, shutdown, config, handler)— DLQ subscription; the handler takes a typedDeadLetterDelivery(#1181): envelope +dlq.-strippedoriginal_queue+ the derived per-occurrence identity (valid_parkcapture first — carried verbatim through the parking hop, #1197 — thenx-death, then the validated_dlxwrapper, else tokenless) behindoccurrence_token()/occurred_at()/park_count()accessors, and returnsResult<(), DlqError<E>>— the handler CLASSIFIES its failures (Disposition::{TransientRetry, TransientPark, Permanent}, ADR-059 §D2) and the session retries in-session, parks, or quarantines, acking only after a durable anchor (#1197; settlement failures tear the session down instead of leaking prefetch credit).configis a validatedDlqRetryConfig { parking_ttl, park_cap, handler_attempts, depth_sample_interval }(typed refusal before any connection); the subscription also runs a read-only depth sampler feeding the #1199dlq_queue_depthgauges for the family queues (metrics::dlq; craig-security recordsdlq_outcomes_totalvia the re-exportedDeadLetterOutcome).OccurrenceTokenis a bounded validated newtype;publish_dlxtakes the caller-derived token (the inbox derives it deterministically from durable state — see ADR-022 §#1181). -
handle_idempotently(db, publisher, consumer_queue, envelope, handler)— the ADR-062 single-transaction attempt machine (#1053, supersedes ADR-022 §D3.2): ONE domain transaction per attempt commits the identity claim, the handler’s effects, and the completion stamp atomically. The handler isfor<'t> FnOnce(EventEnvelope, &'t mut Transaction<'static, Postgres>) → BoxFuture<'t, Result<(), E>>(craig_mq::BoxFutureis re-exported); every domain write goes through the transaction, and the handler must never commit/roll back — the substrate owns completion. Timeout hierarchy: claim lock-wait under the default 30 s statement timeout,SET LOCAL 5sonly after the lock, a 15 s attempt deadline. Failure accounting rolls back TO a savepoint so the claim row survives as the accounting row andINBOX_MAX_RETRIES(5) is hard over counted attempts; panics are two-tier (construction/poll caught + counted; drop/Display-render escape to the #1203 task boundary, whole-tx rollback, uncounted). Envelope identity is the triple(source_service, event_type, payload_hash)(payload_identity_hash, hex SHA-256; NULL legacy rows skip the hash leg and backfill at the next success stamp); a reused id with different content lands inevent_inbox_collisionswith adlxcol:token and nacks without requeue — the canonical row is never touched. The terminal DLX surface is mandatory-published from durable row state (occurred_at:= storedreceived_at— re-surfaces byte-identical); a failed/unroutable surface leavesfailed_atNULL and nacks requeue-TRUE (the source-queue copy is the durable envelope). Callers propagateInboxErrortosubscribeunmapped — the subscriber’s settlement keys on itsDisplay.consumer_queue(#1196, epic &75 C1) stays the subscription’s own queue name: the surface routes underdlq.<consumer_queue>and records it as_dlx.original_queue, matching the broker dead-letter path. -
The legacy pre-ADR-062 path (
handle_idempotently_at_least_once: three autocommit statements; best-effort at-least-once — the #1178 defect) was DELETED with its last caller in A6 (#1244, epic &77): every consumer service now runs the transactional attempt machine.
craig-mq-error (#832, ADR-047)
Leaf crate holding StageError { Serialize(serde_json::Error), Db(sqlx::Error) } — the typed error
stage_event returns. It lives in its own crate (deps: serde_json + sqlx + thiserror only) so
craig-common can provide From<StageError> for ApiError WITHOUT craig-common depending on
craig-mq (amqp) or craig-mq depending on craig-common (axum). Mirrors the craig-validation
leaf-crate precedent; see ADR-047 for the layering rationale.
#1229 adds panic_message_bounded (moved from craig-mq, which re-exports it)
PANIC_MESSAGE_MAX_BYTES (previously a craig-mq private const, now pub here): the leaf home
lets craig_common::supervisor extract bounded panic text without a lapin dependency — the
same layering rationale as StageError.
craig-fault-core (epic &83 C9 / #1504, ADR-067 §D7)
Leaf crate (deps: parking_lot only) holding OpCountdown<K> — the shared
"fail the next N calls whose key matches the armed key, then pass" countdown at the heart of the
contested-environment fault injectors (CipherErrorInjector in craig-crypto,
ObjectStoreErrorInjector in craig-store). It lives in its own leaf so those injectors SHARE the
countdown body instead of duplicating it (the quality-budget B8 duplicate-block gate flagged the
copy when C9 added the third injector). Arc-backed, so a cloned host (axum clones its state per
request) observes ONE counter. Test-support only: each host crate pulls it behind its default-off
fault feature (craig-crypto/fault-injection, craig-store/test-util), so it is absent from every
release artifact — the release-artifact-gate lint proves it via those host features.
craig-crypto
-
FieldEncryptor— AES-256-GCM-SIV column-level field encryption.from_env()loads the key per theCRAIG_<SVC>__ENCRYPTION_*configuration; used for encrypted PII columns (e.g. craig-cases narratives). Encryption is fail-closed per ADR-020. -
hmac_domain(field_domain, version, canonical) → Result<String>+BLIND_INDEX_SCHEME_V1(ADR-049, C3 MR1) — per-field domain-separated deterministic blind index (HKDF infocraig-crypto/blind-index/<domain>/v<version>; takes bytes, soDatecanonicalization works; no cross-column HMAC correlation). The global-domainhmac()stays in the public API (KAT-pinned) but has no production callers post-C3 — every SSN site migrated to the per-field domain. Callers go throughcraig_search::FieldSpec::blind_index, not this primitive directly.
craig-search (ADR-049)
Generic capability-based encrypted-search framework (C3 / sub-epic &67). Leaf crate: no
craig-common dependency; sqlx is an optional feature (executor glue only — it also pulls
tokio for the #1138 concurrent page+count try_join!), so the capability core + planner are
database-free.
-
Capability model (
capabilitymodule):FieldSpec { column, logical_type, scheme };FieldScheme(Plaintext|Opaque { at_rest }|BlindIndex { at_rest, spec, sibling_hmac });LogicalType;OpaqueKind;BlindIndexSpec { field_domain, version, canon, codec }(+Canon,Codec);const fn capability(scheme, type) → Capability { substring, equality, ordering }— the whole search-capability policy in oneconsttable (substring ⇒Plaintext+Text;Opaque⇒ nothing).FieldSpec::blind_index(enc, &BindValue)is THE single blind-index derivation (canonicalize per the registry spec, thenhmac_domain) — planner query, service write, seeder, and verify-seed all call it. -
Predicate AST (
predicate):UserPredicate(Equality/Substring/Relation),SubstringTarget(Field/Concat/AnyOf),RelationSpec(IN-subquery join filters),ParamId. Constructors areconst fn`s that `assert!the required capability, so an illegal scheme/operation pairing is a compile-time error. -
Planner (
plan, withentity+runtime):SearchEntity(entity— the descriptor trait binding a service DTO asFilters) +plan(…) → SearchPlan { table, where_sql, binds, order_by, order_dir }. Injection-safe by construction (SQL from registry&'staticliterals
$Nonly; user values reach onlyBindValuebinds),WHERE TRUEbase, bound NULL-guards for absent filters, LIKE metacharacters escaped (ESCAPE '\'), fail-closed (UnsupportedFilter/UnenforceablePolicyerror, never widen). Inruntime:ScopeConstraints(None/Worker/Supervisor) — the separate non-degradable policy channel (unconditional equality, no NULL-guard);SearchMode { Optional, Required }mirroringEncryptionModewithout thecraig-commondep;BindValue.SearchRuntime(plan) carries the optionalFieldEncryptor+ mode. -
Executor (
exec, featuresqlx):execute_search::<T>(pool, &plan, projection: Option<&Projection>, limit, offset) → (Vec<T>, i64)— ONE plan feeds both the list and the count query, so they cannot drift.projection: Nonerenders the legacySELECT *;Somerenders the validated column list (#1158 — heavy columns never fetched;Tdecodes the projected shape). List SQL itself renders via the sqlx-freeSearchPlan::list_sql(projection). -
Read projection (
plan, #1158):Projectionwraps&'static [&'static FieldSpec];Projection::newvalidates ONCE (refuses empty / duplicate column names —EmptyProjection/DuplicateProjectionColumn, both 500-class), so rendering is infallible;fields()exposes the SAME slice so a consumer’s decrypt walk and SELECT share one binding (the craig-cases reports summary seam is the first instance). Column provenance is registry literals by convention — pin per instance with a subset-of-ALLtest. -
Write/decrypt walk (
row):EncryptableRow+FieldRef(Text/TextRequired/Jsonb)
theimpl_encryptable_row!declarative column→field table macro (single-siblinghmac: "col" ⇒ fieldor the braced multi-sibling table — #1064 added the latter when persons gained a secondBlindIndexpair);encrypt_row/decrypt_rowwalkencrypted_fields(registry);take_encrypted_fields(src, dst, registry)(#1139) moves retained pre-encryption plaintext into a stored row’s shape for a create-echo response — registry-driven, so a new field can never silently echo ciphertext (RowSlotKindMismatchon shape disagreement). A registry-encrypted column the shape does not expose is a hardRowMissingColumn/RowMissingHmacSlot(fail closed and loud, never a silent plaintext write); JSONB uses the{"v": "<ct>"}envelope; aBlindIndexsibling is derived from the plaintext viaFieldSpec::blind_indexbefore the base encrypts (absent base clears a stale sibling; keyless ⇒NULL). -
SearchError(thiserror):Crypto,UnsupportedFilter,UnenforceablePolicy,MissingKeyRequired,CanonMismatch,TooManyBinds,RowMissingColumn,RowSlotKindMismatch,RowMissingHmacSlot,CiphertextWithoutKey,JsonbCodec,EmptyProjection,DuplicateProjectionColumn— ADR-020’s fail-closed matrix, uniformly typed.From<SearchError> for ApiErrorlives incraig-commonbehind itssearchfeature (UnsupportedFilter→ generic 400 with no field oracle; server-side faults → redacted 500; ADR-047 layering precedent). -
validate_registry/RegistryError— runtime well-formedness theconstlayer cannot cover: dangling/mistyped_hmacsiblings, duplicate columns, duplicate blind-index domains (cross-column correlation), invalid domains, out-of-envelope blind-index bases (UnsupportedBlindIndexBase). Called from each registry’sregistry_is_well_formedtest.
craig-cases-fields (ADR-049)
The craig-cases instance of the craig-search capability model — per-entity FieldId enums
FieldSpec registries for reports, referrals, persons, cases, investigations (each
module exposes spec(), ALL, ALL_FIELD_IDS, and a registry_is_well_formed test).
Deliberately sqlx-free (deps: craig-search core + craig-crypto) so tools/craig-seed and
xtask verify-seed consume the same registries as the service. The SearchEntity search
descriptors and EncryptableRow write tables deliberately live in the craig-cases service next
to the DTOs/row types they bind (which pull sqlx) — this crate stays the pure capability source
of truth both consult. The one BlindIndex field is persons.ssn_last_four
(("ssn", 1, AsIs) + sibling ssn_hmac); everything else is Plaintext or Opaque.
craig-bootstrap (Plan Q Step 2)
Shared post-bootstrap setup helpers layered on craig_api::bootstrap(); absorbs the ~85% of
service-main setup duplicated across services.
-
spawn_outbox_worker(db, publisher, service_name, shutdown) → JoinHandle<()>— drainsevent_outboxrows (staged by domain handlers in-tx) to RabbitMQ; watchesshutdown. -
init_object_store() → Result<Store>— loadsObjectStoreConfig+ opens theStore(local FS in tests, Garage in devstack, S3 in prod). -
build_shared_http_client(pkg_name, pkg_version) → Result<reqwest::Client>— wrapscraig_common::build_shared_client()with an identifying User-Agent. -
attach_standard_extensions<S>(router, ctx: StandardExtensionContext) → Router<S>— layers the standard axum extension stack (authz + jurisdiction + authz_ctx + service_token + actor_issuer); generic over router state. (#1186 removed the formermq_healthlayer — MQ health ridescraig_api::AppState.mqasMqRequirement::Requiredso the outer-router health handlers can see it.) -
build_authz_source(http_client, env_prefix, service_token) → Arc<dyn RulesetSource>— reads<prefix>__RULES_ENGINE_URL; picksRulesClient::with_service_tokenvs anonymous. -
policy_ttl(seconds) → Duration. -
AuthzEngine boot (
craig_bootstrap::authz):AuthzBootSpec<'a>(authz_source, subscriber, service_name, jurisdiction, policy_ttl, eval_budget, require_full_coverage, shutdown, audit_sink — ADR-050, required, never defaulted — and workers — #1186 U4: theWorkerHealthregistry on which the boot registersauthz-invalidation
authz-ttl-refreshas Critical workers, plus — #1228 — theauthz-evalliveness sentinel (Critical) watching the dedicated!Send-eval OS thread viaZenAuthzEngine::eval_thread_ended/WorkerHealth::watch_liveness; the bounded ≤20-attempt warmup one-shot is dropped-detached by design);AuthzBootResult(engine, engine_concrete — the three join-handle fields are gone, supervision owns the tasks);boot_authz_engine(spec) → Result<AuthzBootResult>— the ~80-LOC sequence (boot → retry once → degrade toZenAuthzEngine::boot_degraded, an empty cache over the REAL source (#786 — the former empty-InMemoryRulesetSourcesubstitution falsely attested universal absence and never self-healed; withrequire_full_coveragea double fetch failure refuses to boot); enforce coverage; spawn invalidation + warmup + TTL).
craig-api
-
request_claims(ADR-062 §B, #1245 — epic &77 B1a): THE transactional HTTP idempotency substrate (the response-cache middleware was DELETED in B2 #1194 — zero senders; theIdempotency-Keyheader is no longer honored anywhere; the orphanedidempotency_responsestables were DROPPED ×8 in BF #1270). Each service prunes itsrequest_claimstable hourly via therequest-claims-retentionworker (craig_mq::spawn_local_sweep, advisory lock "CRAIGRCL", 5k batches) on the deployment-globalCRAIGREQUEST_CLAIMSWINDOW_DAYShorizon (default 30;0⇒ registeredDisabled(info log); an unparseable value also disables, with an ERROR log — never a guessed horizon; therequest_claims_retention_overrunwatchdog is the second indicator). NewtypesClientRequestId/ClaimScope/EntityKind/IntentHash(grammarv1:<64 hex>, constructible only viaintent_hash_v1);intent_hash_v1(scope, jurisdiction, actor, path, &intent_v1_body)hashes the versioned envelope{v, scope, jurisdiction, actor, path, body}(normalization pins in the module doc: lowercase-hyphenated UUIDs, byte-as-submitted strings, moneyrescale(2)in the projection ctor,None⇒ explicit null, no default injection);claim_first(tx, &spec, entity_id)= the FIRST statement of the domain tx (one lockingINSERT … ON CONFLICT DO UPDATE … RETURNINGwithxmax-based insert detection) →Claimed|Replay { entity_id }| 409IDEMPOTENCY_CONFLICT(one fixed body for every mismatch class — no leak);precheck(executor, &spec)= the unlocked pre-volatile-deps fast path, never the guard;replay_entity_gone(kind)= the typed 409 for a replay whose entity vanished. Backing tablerequest_claims(identical migration ×8 DB-owning services; 30 d prune horizon — knob + pruner ship in B2). -
AppState(Clone) —db: DbPool,auth: AuthLayer,mq: MqRequirement(Required(MqHealth)|NotApplicable; noOption, noDefault— omission is a compile error),workers: WorkerHealth(#1186 / ADR-061). -
Supervisor(supervisormodule, #1186):install()(creates THE process shutdown token
registers thesignal-bridge),token(),expect(&[WorkerSpec]),health() → WorkerHealth(the registry handle:watch/watch_optional/watch_liveness/mark_disabled/snapshot),drain(WORKER_DRAIN_DEADLINE),check_exit(). Mains followserve(…).await?; supervisor.drain(..).await; supervisor.check_exit()?;. #1229: the module (withshutdown_signaland theWorkerCheckEntrywire mapper) LIVES incraig_common::supervisorso the DB/MQ-less BFF can supervise its workers; everycraig_apipath above is a re-export and stays valid. -
ServerOptions { cors_origins: String, body_limit: usize }(default 2 MiB). -
ApiServer—router(state, service_routes, opts, api_doc)(CORS, compression, tracing, auth, body limit; mounts/livez+/readyz+/healthz+/metrics, optional Swagger UI at/swagger-ui);serve(router, port, shutdown: CancellationToken)with the boundedHTTP_DRAIN_DEADLINE(20s) HTTP drain, delegating to the test seamserve_with_listener(listener, router, shutdown, drain_deadline). -
Bootstrap helpers (
bootstrap.rs, re-exported):bootstrap(prefix, service_name) → (settings, BootstrapResult { db, auth, publisher, subscriber, _telemetry, mq_health })(used by 7 of 8 services); granular primitivesconnect_database/init_auth/connect_mq(for non-standard compositions; craig-intake’s integrated mode formerly used them and is now MQ-less — aconnect_mqcaller composing its own channels gets connection-only readiness gating until it attaches a publisher channel, #1235);shutdown_signal()(SIGTERM/SIGINT / Ctrl-C — the formershutdown_token()free-token helper is retired by #1186; useSupervisor::install()). Since #1226/#1227 the JWKS refresh loop is token-aware, and since #1236 the #1160 pool acquire-wait probe is too — both rideBootstrapResult.background(BackgroundWorkers: one shared shutdown token + named handles) toadopt_background_workers(&supervisor, br.background.take())— registeredObserved(jwks-refresh+db-pool-probein every service’s worker set) with the supervisor’s shutdown bridged in once;OidcDiscovery::start_refresh_taskcarries the same token-aware contract (craig-web’s three detached refresh tasks — one OIDC discovery + two JWKS verifiers — stay detached pending its #1229 supervision story). Since #1307 (ADR-063)bootstrap()is a thin composition of the phased halvesbootstrap_data_plane(prefix, service_name) → DataPlane { settings, db, _telemetry }→bootstrap_control_plane(prefix, service_name, DataPlane). The 8 stateful mains boot throughbootstrap_verified(prefix, service_name, &Migrator)(M3b): data plane →verify_schema(FloorCheck::Enforce, gate hint derived from the service name)→ control plane — verify-only serving boot, structurally ordered so a JWKS/MQ failure can never mask a schema refusal;BootstrapError::SchemaVerifycarries the remedy-first refusal. -
Migrate process mode (#1307, ADR-063 D2/D4,
migrate_mode.rsre-exported):run_migrate_mode_if_requested(env_var, service, &Migrator) → Result<bool, MigrateModeError>— the<binary> migrategate. Exact argv shape (migratealone;--print-openapitogether is a typed conflict; non-Unicode argv is a typed error); called BEFOREprint_openapi_if_requested(it does no I/O on the not-this-mode path); logging-only telemetry (no OTLP); ONE required env (CRAIG_<SVC>DATABASE_URL); the corerun_migrate_gate(url, service, &Migrator) → ApplyOutcomebuilds a minimal 1-connection pool (recorded deviation:DbPool::connect_with, not rawPgConnection— reuses the testedapply_by_verdictpath; intent kept: no metrics, no probe) and applies by verdict. Since M3a the 8 mains callrun_process_modes_if_requested(service, &Migrator, &OpenApi) → Result<bool, ProcessModeError>— the ONE home of the mode-ordering contract (migrate first,--print-openapisecond) and of theCRAIG_<SVC>DATABASE_URLname derivation. -
multipartmodule (#462) —parse_upload_multipart(multipart, max_upload_bytes, text_fields) → Result<(UploadedFile, HashMap<String, String>), ApiError>(streaming size-cap + named text fields);UploadedFile { filename, content_type, data }. The canonical bootstrap orchestrator shape services follow is in Developer Guide § Service Initialization.
craig-retention (#1129, ADR-058)
The audit-class archive-then-prune engine both archiving services (craig-security, craig-rules)
wire their tables into. One batch = one crash-atomic unit: oldest rows past the hot window →
NDJSON + sha256 → no-overwrite conditional puts (data THEN manifest, fresh UUIDv7 archive_id) →
ONE transaction: id-keyed DELETE (count-mismatch rolls back) + the service’s BookkeepFn closure
(local ledger row + staged event) → commit. Bounded round-robin passes (ENDPOINT_PASS_BUDGET 8 /
SCHEDULED_PASS_BUDGET 120) under the retention-archive advisory lease on a detached
connection; poison rows quarantine with an in-pass exclusion set (D13); per-table error isolation.
Public surface: ArchiveEngine::{new, run_pass}, ArchiveTable/ArchiveTableSpec/BookkeepFn,
ArchiveManifest/ColumnSpec/schema_fingerprint (the self-describing sidecar: full column
shape + fingerprint, [min, max] id range per D17, D9 store identity), AdvisoryLease,
boot_probe (D10 write/read/delete probe + the D9 Local-backend refusal — deliberately OUTSIDE
the engine so it stays testable against a tempdir store), spawn_archive_worker (takes the
operator’s enabled consent explicitly, D11 defense-in-depth), and the
retention_quarantined_total / retention_bookkeep_divergence_total /
archive_last_success_timestamp otel instruments (stub twins without the otel feature).
Load-bearing library defaults (#1168, audit F33): the engine’s object I/O (put_create
data/manifest, purge deletes) is bounded ONLY by object_store’s client defaults — the puts are
bare awaits and craig-store sets no `ClientOptions/RetryConfig. Verified against the vendored
0.13.2 source: 30 s per request + 5 s connect (client/mod.rs ClientOptions::default), 10
retries under a 180 s retry budget (client/retry.rs RetryConfig::default). These defaults are
what refuted the audit’s "stalled upload delays shutdown indefinitely" — worst case an in-flight
batch holds shutdown ≈ the 180 s retry budget, not forever (and the timeout-less Local backend is
already refused at boot per D9). On object_store upgrades: re-verify these two Default
impls; if they loosen, pin explicit ClientOptions/RetryConfig in craig-store instead of
inheriting the change silently (ADR-058 § Amendment #1168).
craig-store
-
ObjectStoreConfig—CRAIG_STORE__*env. Fields:backend(Local/S3),bucket,local_root,s3_endpoint,s3_region,s3_access_key,s3_secret_key,max_upload_bytes(default 50 MiB);load();Debugredacts credentials. -
StoreBackend(Local|S3);Store(Clone) —from_config(config),put/get/delete/list/health_check/max_upload_bytes. -
StoreError(ObjectStore / NotFound / TooLarge / DisallowedContentType / InvalidFilename / Config);From<StoreError> for ApiError. -
validate_upload(content_type, size, validation);sanitize_filename(name)(strips/,\, NUL; trims; truncates to 255);UploadValidation { max_bytes, allowed_mime_types };DEFAULT_ALLOWED_MIME_TYPES(12 types). Import note:use object_store::ObjectStoreExt as _. -
attempts(ADR-062 §U, SU1 #1247) — the generation-fenced, digest-verified upload-attempt state machine:UploadAttempt/NewUploadAttempt/AttemptStatus/FailureReason;claim_attempt(row id =client_request_id; the locking upsert) + the CAS transitionsmark_stored/finalize_attempt(promote-lock-first +AttemptFinalizereffects + result stamp, one tx)/supersede/fail_attempt/reopen_attempt(generation++ + key rotation);classify_replay(existing, probe) → ReplayDecision(the §U decision table, pure);store_blob_create(declared-digest gate → read-back precheck →put_create; convergent even on non-honoring backends — devstack Garage ignoresIf-None-MatchBY DESIGN, see thegarage_put_createcanary) +verify_stored_blob;sha256_hex/derive_object_key({kind_prefix}/{target_path}/{attempt_id}/g{n}/{safe_name})/rotated_object_key/wins_pointer(the(created_at,id)replace-kind order);UploadAttemptReconciler(1 h grace / 5 min interval / batch 100 / 31 d tombstone retention; digest-verified promotion, reap+event one tx, abandoned-key sweep, terminal prune). Theupload_attemptstable ships per-service in SU2; consumers integrate in SU3–SU6.
#1129 (ADR-058 D9/D12): Store::put_create (PutMode::Create — typed
StoreError::AlreadyExists, mapped to 409; archive objects are immutable once written),
Store::delete_if_exists (NotFound → Ok(false) — the idempotent purge delete), and
Store::is_local_backend (the archiver’s boot refusal probe); StoreBackend is re-exported at
the crate root.
craig-reference
-
Domain enums (strum 0.27: Display/EnumString/EnumIter + serde snake_case + utoipa ToSchema): demographics (
Gender,Race,Ethnicity,MaritalStatus,Language,MilitaryBranch); intake (ReporterType,Priority,AbuseType,Disposition,RelationshipToChild,RelationshipToCaregiver,MaltreaterRelationship,ChildRelationshipToCaregiver); cases (HouseholdRole,PermanencyGoal,ClosureReason); contacts/court/placement (ContactType,CourtOrderType,LicenseType,PlacementType,PlacementEndReason,RemovalManner); exchange (ExchangeDirection,IcpcDirection,IcpcRequestType); financial (PaymentType,SubsidyProgram— 7 GA 22.8 kinship programs incl. closed cohorts,const fn open_for_enrollment;SubsidyAgreementStatus— 6-state ADR-052 interval vocabulary; plus theSUBSIDY_*_REASON_CODESconsts +subsidy_reason_codes(status)service-validated vocabularies, #1067;SubsidyProgramFamily+const fn program_family(program)— the #1071/ADR-057 D8 family taxonomy{err}/{sg, nrsg, ersg, enrsg}/{rcs, ercs}with.programs()per family and the canonicalSubsidyProgramFamily::ALLorder every inter-family lock taker shares, so create-vs-import stays deadlock-free by construction).PartnerTypewas deleted at Plan T3.3 (partner_type is an open snake_case token validated byPartnerTypeRegistrymembership). -
fipsmodule —Stateenum (50 + DC + 5 territories, USPS abbreviation);AdminUnit { name, fips_code, state, unit_type };admin_units_for_state(state);State::fips_code(). -
translatemodule —admin_unit_to_fips/full_fips_code;*_str_to_afcars/*_str_to_ncands. -
federal_partner_category(Plan T1.4) —FederalPartnerCategory9-variant federal-closed enum (tanf ccwis afcars ncands iv_e icpc medicaid education_slds child_support); consumed byStateBundle::federal_mapping()+FederalPartnerMappingRegistry; nosqlx::Type. -
afcars/ncandsmodules — gender/race/ethnicity/permanency/placement/abuse/reporter/disposition conversions (+ reverse). Thencandsmodule carries two families: the literal NCANDS Child File codes (reporter_type_to_rptsrc/disposition_to_rptdisp/abuse_type_to_chmal/gender_to_chsex/ethnicity_to_ncands/race_to_ncands, numeric, codebook rev 2025-09-12 — federal-wire-ready, returningNonewhere CRAIG has no faithful code per ADR-040; added #644) and the legacy*_to_ncandsshort mnemonics (a CRAIG-internal vocabulary, never emitted to a federal file).validationmodule —validate_enum<T>+ 20+ specific validators.
craig-validation (#486)
-
Canonical DTO field-length cap constants shared across the contracts crates so every
#[garde(length(max = …))]references a named cap:NAME_MAX,PHONE_MAX,IDENTIFIER_MAX,IDENTIFIER_LONG_MAX,CODE_MAX,ENUM_STRING_MAX,SHORT_TEXT_MAX,DESCRIPTION_MAX,REASON_MAX,NARRATIVE_MAX,NARRATIVE_ENCRYPTED_MAX,NAME_ENCRYPTED_MAX,SSN_LAST_FOUR_MAX. -
FieldError— the wire shape for a field-level validation error. -
Sha256Hex(epic &65 / #940) — a construction-validated lowercase-hex SHA-256 digest newtype (exactly 64 hex chars; (de)serializes as a bare JSON string via serdetry_from/into), plus theMAX_ATTACHMENTScount const (5). Backend-agnostic so theIntakeSinkseam and the SHINES signed-attachment manifest (ADR-042 §D9) share one type without the trait referencing a backend.
craig-matching (ADR-019)
-
Pure-Rust signal computation for the report-person linking pipeline (Layer 1 of ADR-019; no DB/HTTP).
-
JsonbEntry { first_name, last_name, date_of_birth: Option<NaiveDate>, dob_approximate, gender };PersonRecord { id, first_name, last_name, date_of_birth, gender }. -
SignalSet { name_exact, name_similarity_score, dob_exact, dob_within_30, dob_year_match, gender_match, phone_match, name_similarity_algorithm }. -
compute_signals(entry, candidate, &dyn NameSimilarity) → SignalSet(pure). -
NameSimilaritytrait (score(a, b) → f32in [0,1] +algorithm_name());TrigramJaccardimpl (strsim-backed). v1:phone_matchis hardcodedfalse(nophonecolumn yet).
craig-cases-contracts
-
The shared request/response + event DTO types for craig-cases, used by the service, the BFF, the CLI, and the test-lib clients (so the wire shapes stay single-sourced). Field caps reference
craig-validation; errors map toApiError. -
SsnDigest(1430, planPlan::SSASD12) — the versioned SSN blind-index digest newtype. Canonical wire form is one stringv{version}:{44-char base64}(serdetry_from/intoString; the version is the per-field blind-index rotation version, stamped by the producing boundary).PartialEqIS the constant-time compare (subtle::ct_eqover the full canonical bytes; noHash/Ord);Debugredacts the digest (version only) and there is deliberately noDisplay;SsnDigestErroris categorical (never echoes input). Wire-only in A4 — nosqlx::Type(B1 decides the column shape); noToSchema(consuming DTO fields annotate[schema(value_type = String)] +#[garde(skip)], the Sha256Hex precedent). B1 (#1462) decided the column shape: the tagged single TEXT —ssa_screening_members.ssn_digeststoresas_str()verbatim under a mirroring grammar CHECK. -
craig-exchange-transport::EgressControlledTransport(#1465, planPlan::SSAB4 / S13) — the hardened egress the typed SSA worker sends through (the genericDirectHttpTransportis untouched): https-only with a literal-loopback http dev carve-out, exact-host allowlist (empty = deny-all;EgressPolicy::from_hoststakes the comma-split settings shape), DNS resolve-vet-PIN (https-class hosts must not resolve into internal address space; the connection binds to the vetted address), redirects surfaced asBadStatus, and the 1 MiB chunked-read response byte cap (MAX_RESPONSE_BYTES— the A1-deferred cap).check_egressis the no-send pre-check the worker runs BEFORE its custody read. Every refusal is a categorical `egress: `-prefixed token — no URL, host, or body byte echoes. -
persons::{SsnReleaseRequest, SsnReleasePurpose, SsnReleaseResponse}(#1463, planPlan::SSAB2) — the value-bound custody-release wire (ADR-065 §D2):SsnReleaseRequest.expected_digestis the FIRST productionSsnDigestconsumer;SsnReleasePurposeis a closed enum (serde refuses unknown tokens — a new purpose is a code change with its own allowlist review); the correlation trio (screening_run_id/screening_member_id/requested_by) is audit attribution asserted by the allowlisted exchange service, not authentication. -
screening_wire(#1464, planPlan::SSAB3) — the screening request-path wire: the cases intent body (RequestCaseScreeningBody), the staging request/response (StageScreeningRunRequest/StageScreeningRunResponse, members carryingSsnDigest), housed HERE (notcraig-exchange-contracts, a deliberately pristine leaf) because the staging wire IS the screening cohort in motion; both services consume it.requested_by/requested_by_nameare stage-time attribution asserted by the allowlisted cases service (the §D2 posture). -
screening_cohort(#1462, planPlan::SSAB1 / H1) — the ONE shared definition of the SSA screening cohort binding:cohort_hash(&[(person_id, &SsnDigest)], as_of)(sha256 hex over the domain tag + as-of + sorted pairs frameduuid \0 digest \n— the ADR-057manifest_checksumprecedent; binds the FULL versioned canonical digest, so a rotation changes the hash),SCREENING_FRESHNESS_MONTHS(= 12, UD11 RATIFIED policy — deliberately a const, not a knob) andscreening_is_fresh(stale ON the anniversary, fail-closed on calendar overflow). craig-exchange stores the hash at stage time (B3); cases-side D3 assembly recomputes it over the current household to decide whether a run’s witness is still assertable.
craig-rules-contracts (#305)
-
The shared request DTOs for the craig-rules HTTP boundary —
rule_sets::{CreateRuleSetRequest, UpdateRuleSetRequest}+evaluation::EvaluateRequest— used by the service and the test-libRulesClientso the request wire shapes stay single-sourced. A leaf crate (serde / serde_json / uuid / utoipa only; never depends onservices/*orcraig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). Thecontent/inputfields carry zen-engine-polymorphic JDM asserde_json::Value. Response types (store::RuleSet,store::RuleEvaluation) remain in craig-rules — they aresqlx::FromRowstore models, not yet lifted (tracked with the broader response-typing follow-up).
craig-reporting-contracts (#304)
-
The shared request DTOs for the craig-reporting HTTP boundary —
afcars::GenerateAfcarsRequest+ncands::GenerateNcandsRequest— used by the service and the test-libReportingClientso the request wire shapes stay single-sourced. A leaf crate (serde / utoipa / garde /craig-validationonly; never depends onservices/*orcraig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). Thegardefield-length validation derives move with the DTOs (soreporting_periodstaysCODE_MAX-capped). Response types (store::models::{AfcarsSubmission, NcandsSubmission, …}) remain in craig-reporting — they aresqlx::FromRowstore models, not yet lifted (the broader response-typing follow-up).
craig-financial-contracts (#303)
-
The shared request DTOs for the craig-financial HTTP boundary —
rates::{CreateRateRequest, UpdateRateRequest}+payments::CalculatePaymentRequest+adjustments::CreateAdjustmentRequest+claims::GenerateClaimRequest— used by the service and the test-libFinancial*Clientfamily so the request wire shapes stay single-sourced. A leaf crate (serde / utoipa / garde /craig-validation+ the field-type cratesuuid/rust_decimal/chrono; never depends onservices/*orcraig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). Thegardefield-length validation derives move with the DTOs (the handlers'body.validate()?calls behave identically).rust_decimal::Decimalcarries the workspaceserde-strfeature, so money fields ride the wire as strings.payments::PaymentResponse(#978) is the first lifted response type — the client-side wire mirror of the payment row the lifecycle endpoints return (nullable-origin since #1068: the placement trio isOption, andagreement_id/term_idmark subsidy-origin UNIT-MONTH rows —daily_rate = gross =the monthly award); the remaining response types (store::models::{RateTable, PaymentAdjustment, ClaimingRecord}) stay in craig-financial assqlx::FromRowstore models (the broader response-typing follow-up).subsidy_generation(#1068/ADR-053) adds the generation-trigger pair:GenerateSubsidyPaymentsRequestwith the strict canonicalYearMonthtoken (one parser, property-tested: never-panics, byte-identical roundtrip, exact grammar) and the per-runGenerationReportcounters both the endpoint and thefinancial.subsidy_generation_completedevent carry (#1070 F2 adds the serde-defaultedskipped_before_payments_begin— months skipped before a guardianship agreement’s stored money boundary; #1071 D5 adds the serde-defaultedskipped_before_cutover— months before an imported agreement’spayment_cutover_month; both absent on older reports).subsidy_agreements(#1069 M2/ADR-055, reshaped by #1070 U1/ADR-056) carries the native enrollment surface:CreateSubsidyAgreementRequest(body ofPOST /v1/financial/subsidy-agreements) is a#[serde(tag = "program")]7-variant enum —Err(CreateErrAgreementBody)is the #1069 one-shot body VERBATIM (untagged serialization byte-stable, so persisted F8 canonical hashes survive the upgrade; the wire gains only the tag),Sg/NrsgshareCreateGuardianshipAgreementBody(the two-step PENDING mint:signed_at+ the anchoringplacement_id, the U3 per-program relationship-evidence rule — required non-blank for sg, optional for nrsg — the requiredcaregiver_assessment_evidence_key, guardianship-family-onlypredecessor_agreement_idlineage; the F8 canonical hash covers the TAGGED body), and the four closed cohorts (Ersg/Enrsg/Rcs/Ercs) share the emptyCreateClosedProgramBodythe service refuses with the typedclosed-programproblem.CreateSubsidyPartyRequest(1–10 signatories, ≥ 1caregiver, never the child — validated server-side) rides both live arms. Every body deliberately omits what is derived server-side (child/case/worker + the residence-start snapshot from the verified placement, coverage/terms starts, review anchors, the ERR approval clock, all approval attribution);client_request_idis the F8 idempotency key — exact replay returns 200 with the existing agreement, same-id different-payload is a typed 409, checked against the canonical payload hash BEFORE any volatile dependency.ActivateSubsidyAgreementRequest(#1070 F4/F7, body ofPOST /v1/financial/subsidy-agreements/{id}/activate) carries NO dates beyond the optional past-datedtanf_terminated_onattestation —transfer_onis DERIVED from the anchoring placement’s S2S-projected end date, never caller-typed; fields: the CASexpected_head_interval_id, the requiredcourt_order_evidence_key,tanf_terminated_on?,legal_reference?. Replay recognition keys on the transfer alone — differing evidence keys / attestation / legal reference on a retry after success are IGNORED, never merged.SubsidyAgreementDetailgains the five serde-defaulted #1070 columns (guardianship_transfer_on,payments_begin_month,court_order_evidence_key,caregiver_assessment_evidence_key,tanf_terminated_on).subsidy_imports(#1071/ADR-057) carries the conversion surface:ClosedCohortProgram(a 4-variant serde enum —rcs/ercs/ersg/enrsg; open programs are UNREPRESENTABLE at the wire, D2),CreateImportBatchRequest,StageImportRecordRequest(the FULL historical record: identity + parties + interval/term chains, each entry carrying its recordedImportStampRecord— the D9 posture means NO server facts and NO authz denorms exist on the DTO),ImportWarning/ImportBlocker/PaymentAssessment(the persisted stage outcomes),StageOutcome+ theStageOutcomeKindreplay vocabulary (staged/already_staged/restaged/already_imported/rejected),ImportRecordView/ImportBatchDetail/ImportBatchCounts/ImportRecordPage, andFinalizeReport/FinalizeRejectionRow. THREE pub canonicalization fns live HERE so the service and the xtask conversion tool cannot drift (risk 4 — a tool-hash == service-hash parity vector pins it):canonical_record_payload(deterministic re-serialization — BTreeMap-ordered keys,nulloptionals, ISO dates, RFC 3339 UTC stamps, money normalized to 2 dp so"450.5"≡"450.50"),canonical_hash(sha256 hex of the compact serialization), andmanifest_checksum(sha256 over the lexicographically-sorted(external_reference, canonical_hash)pairs, NUL/newline-delimited — order-invariant between the tool’s file order and the service’s row order).subsidy_review_sweep(#1069 M3) extends the #1096 sweep contracts with the per-diem-handoff leg:SweepHandoffCandidate(pinned evidence payment +perdiem_started_on, the truth date the termination carries) and the report-onlySweepMovedChild(per diem on a DIFFERENT placement — visibility, never executed) join the preview/run views;ExecuteSweepRequestgrows theacknowledged_suspend/acknowledged_terminate/acknowledged_handoffhandshake (server 409s on any mismatch with the pinned sets — a UI that never rendered a leg cannot execute it);SweepReportandSweepRunCountersgrowleg3_actionable/handoff_terminated/leg3_moved_children, andSweepRunViewgrowsmode_per_diem_handoff— all M3 additionsserde(default)so pre-leg events/rows still deserialize.
craig-placement-contracts (#979)
-
The shared event contract for placement activation — the first contracts crate to carry a cross-service event payload rather than HTTP DTOs.
events::PlacementActivatedPayload+ theevents::PLACEMENT_ACTIVATEDrouting-key constant are compiled by BOTH the craig-placement producer and the craig-financial consumer, so the billing contract (field names,foster_home_idnullability,started_atas the proration anchor) cannot drift the way the untypedplacement.createdshapes did pre-#979. A leaf crate (serde +uuid/chrono; never depends onservices/*orcraig-test-lib). Deliberately carries no child age/DOB — event payloads are PII-free; craig-financial resolves age authoritatively from craig-cases person data. -
events::PlacementEndedPayload+ theevents::PLACEMENT_ENDEDrouting-key constant (#1070 F9) — the placement-ended twin:{ placement_id, end_reason? }, compiled by the producer, craig-financial’s subscription binding, AND its dispatch arm (it replaced an ad-hoc producer-sidejson!whoseend_reasonthe financial consumer silently dropped, making the two sides undriftable).end_reasonisOptionfor wire honesty: the producer omits it when the row has none, and a consumer reading an older envelope degrades to reason-blind behavior, never a deserialization failure. -
subsidy::PlacementSubsidyEligibility(#1069 M2/ADR-055 + #1070/ADR-056) — the minimal subsidy-eligibility projection of one placement (child/case ids, assigned-worker denorm,placement_type/statuswire tokens,started_on), the response of craig-placement’sGET /v1/placement/placements/{id}/subsidy-eligibilityand the type craig-financial’sPlacementClientS2S read decodes. Exactly the facts subsidy enrollment and guardianship activation need (data minimization — the full placement row carries CTW/permanency detail eligibility must not see). #1070 adds three serde-defaulted fields:ended_on(the guardianship-transfer proxy —transfer_on = ended_onwhenend_reason = guardianship;Nonewhile the placement is live, and absence from an older producer means "not ended", so the F7 witnesses refuse),end_reason, andpermanency_goal(the activation witness refusesreunification— the non-reunification-order proxy, ⁂ #1073).started_on/ended_onare the placement’s first/last day on the JURISDICTION’s business calendar (Eastern for Georgia), projected placement-side viaBusinessClock::date_ofso the consumer never converts timestamps. Type/status staysnake_casestrings — the crate is a serde-only leaf; consumers parse intocraig_referenceenums and fail closed on unknowns.
craig-security-contracts (#306)
-
The shared request DTOs for the craig-security HTTP boundary — 14 structs across
admin,changes,detection,nist,reviews,partners, andsigner_keysmodules (admin-unit / major-change / detection-rule / NIST-control / review / partner / partner-key / signer-key create+update bodies) — used by the service and the test-libSecurity*Clientfamily so the request wire shapes stay single-sourced. A leaf crate (serde / serde_json / utoipa / garde /craig-validation+ chrono; never depends onservices/orcraig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). Thegardefield-length validation derives move with the DTOs. The polymorphicfindings/remediation_plan/public_key_jwkfields stay JSON values (STRUCTURAL-VALUE carve-outs);VerifyPartnerRequeststays service-internal (the test-libverify_partnerbuilds its body internally). Response types (store::models::) remain in craig-security — they aresqlx::FromRowstore models, not yet lifted (the broader response-typing follow-up).
craig-intake-contracts (#637)
-
The single shared request DTO for the craig-intake public/partner report boundary —
report::SubmitReportRequest, the body ofPOST /public/v1/reports(and the partner submit path). A leaf crate (serde / serde_json / chrono / utoipa / garde /craig-validation; nouuid/craig-reference— the fields are plainString/bool/JSON values capped by garde, not domain-enum-typed; never depends onservices/*orcraig-test-lib, per the test-framework-hardening §D8.2 contracts pattern). Thegardefield-length validation derives move with the DTO, so the handler’sbody.validate()?is unchanged; the polymorphicchildren/adults/narrativefields stay JSON values (STRUCTURAL-VALUE carve-outs), so the struct derivesPartialEqbut notEq. -
Why single-sourced (the decision note for #637): before #637 the report request shape existed three times — the service’s
SubmitReportRequest, thecraig-webBFF’s hand-builtserde_json::json!envelope coupled to it only by a// matches SubmitReportRequestcomment, and the SDK’sReportSubmission. craig-web now constructsSubmitReportRequestdirectly, so BFF↔service field drift is a compile error (this surfaced a real drift —mandated_reporter_category, collected by the form but absent from the DTO and silently dropped; tracked as a data-loss fix in #638). -
Why the SDK is NOT unified (the trust boundary, #307):
craig-intake-sdk’s `ReportSubmissionbuilder stays a separate, partner-facing ergonomic type (domain enums, its own SemVer). The intake submit endpoint is untrusted ingress — the server owns the typed contract and validates/rejects arbitrary input; the SDK and the test-libIntakeClientspeak the wire shape as external callers (theIntakeClientstays rawValue, #307 closed not-applicable-by-design). Sharing one type across the server’s deserialization target and the partner-facing builder would couple two distinct trust postures — the coupling the #307 analysis rejected.
craig-signing
-
Standalone ECDSA P-256 JWS signing primitives (deliberately minimal deps —
p256,base64ct,sha2— so the client SDKs can share it):canonicalize_json(value) → String(recursive key sort + compact JSON, matches server + browser);hash_payload(canonical) → String(SHA-256 hex);sign_detached(config, payload) → Result<String>(detached JWS compact). The intake SDK’ssigningmodule re-uses the same canonicalization contract.
craig-intake-sdk
-
IntakeClient— partner API client.new(base_url, api_key)(default reqwest, 30 s);with_client(http, base_url, api_key);with_signing(config) → Self(ECDSA P-256 detached JWS auto-sign);submit_report(report) → Result<ReportConfirmation, IntakeError>(3 retries, exp backoff,X-JWS-Signaturewhen signing);check_status(report_id) → Result<ReportStatus>. -
ReportBuilder— validates 20+-char description + required fields (reporter / incident / children / adults / narrative / relationship fields). -
ReportSubmission,ReportConfirmation { id, submitted_at },ReportStatus { id, status, submitted_at, updated_at }(no PII);ReporterType(Anonymous/Mandated/ConcernedCitizen);ConcernType;IntakeError(BadRequest/Unauthorized/RateLimited/NotFound/ServerError/Network; decodes RFC 9457). -
signingmodule —SigningConfig(from_jwk(jwk_json, key_id),key_id()),canonicalize_json,hash_payload,sign_detached(shares thecraig-signingcontract).
craig-exchange-contracts (Plan L + Plan T1.2)
-
Typed
ExchangeAdaptertrait (RPITIT; ADR-038 §1 Tier-T) — unchanged by the erased seam. -
ExchangeAdapterKindenum DELETED at Plan T3.1 (ADR-032 §1.3 open-TEXT carriage) — adapter identity is the&'static strfromErasedAdapter::kind(); validity = registry membership. -
ErasedAdapter(object-safe, Plan T1.2 per ADR-032 A1) —send_value/audit_value/test_connectivity_value/kind/dispatch_class(#1428, REQUIRED — no default, so a hand-written impl must choose); the*valuemethods returnBoxFuture<', Result<…, ErasedAdapterError>>and threadformat: Option<&str>. -
DispatchClass(#1428, exhaustive by design) —Generic(the generic exchange pipeline may stage/dispatchValuepayloads for the kind) vsTypedOnly(the SSA family: every generic checkpoint hard-rejects the kind; payloads move only through a dedicated typed pipeline). -
ErasedAdapterError(thiserror) —Serde(#[from])/Inner(Box<dyn Error + Send + Sync>)/AuditUnsupportedForKind { kind }/TypedOnlyDispatch { kind }(#1428 — categorical, names only the kind token). -
impl_erased_adapter!three-arm macro: typed-partner + SHINES-passthrough (bothGeneric)
typed_only(#1428 —send_value/audit_valuerefuse unconditionally withTypedOnlyDispatch; the payload-free connectivity probe still forwards);pub use futures_util::future::BoxFuture.
craig-exchange-transport (Plan V Step 1)
-
OutboundTransport(object-safe;Send + Sync + 'static) —send(TransportRequest) → BoxFuture<', Result<TransportResponse, TransportError>>+probe(ProbeRequest) → BoxFuture<', Result<(), TransportError>>. The single wire-egress seam (10 partner adapters + SHINES route throughArc<dyn OutboundTransport>). -
Value-objects (public fields):
TransportRequest { endpoint, body: Vec<u8>, headers: Vec<(String,String)>, timeout };ProbeRequest { endpoint, timeout, method: ProbeMethod }(Headpartners /GetSHINES);TransportResponse { status: u16, body: Vec<u8> }. -
TransportError(thiserror, Clone) —Timeout/Connect/BadStatus { status, body }/Transport(String);reqwestis never named in apubsignature. -
DirectHttpTransport::new(reqwest::Client);WebMethodsTransport::new(reqwest::Client, broker_base_url)(.with_service; Plan V Step 3 broker stub — NOT wired into prod boot; #557);StubTransport(behindtest-util— canned-response, request-recording double);pub use futures_util::future::BoxFuture.
craig-partner-audit (Plan L + Plan T1.3)
-
PartnerAuditEventdiscriminated union (11 Georgia partner variants since #1429 — theSsaSolqplaceholder payload joined at A3; closed-aggregator per ADR-032 A2) +decode_jsonb(kind, …)/PartnerAuditDecodeError::UnsupportedKind { kind }— unknown/foreign tokens fail closed. -
AuditCodectrait (impls live here per the orphan rule) —encode(&self, payload: &Value) → Result<PartnerAuditEvent, ErasedAdapterError>. 10<X>AuditCodecunit structs re-exported; SHINES has no codec (fail-closed registry miss). -
The 10 per-partner adapter crates (
craig-partner-{caps,ies,ions,wic,tcm,empi,smile,doe-slds, cprs,stars}) own their typed wire schemas +round_triptests; they route throughcraig-exchange-transportand register via the activeStateBundle. -
craig-partner-ssa-solq(1427, planPlan::SSAA1) — the 11th partner crate and firstDispatchClass::TypedOnlymember; UNREGISTERED until A3. Two departures from the family template: the CLOSED categoricalSsaSolqError(no upstream bytes inDisplay— bodies, transport text, and serde context are discarded at the mapping boundary; S2), and NO in-crate mock module/feature (a mock SSA endpoint would fabricate authority; S10 — test fixtures land in C3). Wire types are field-free[non_exhaustive] placeholders pending the Gateway contract (C0/Phase P); adopts thetyped_onlyerased arm (impl_erased_adapter!(SsaSolqAdapter, "ssa_solq", typed_only)).
craig-plugin-contracts (Plan W Step 1, ADR-033)
-
The stable seam the plugin subsystem is reached through.
PluginManifest::parse(typedPlugin.toml+ validator); the serializable render contract (RenderCtx,FetchOutcome,PanelState,RenderedFragment,PluginError,PluginRenderFn) — owned values only, WASM-ready;FetchOutcome::classify(2xx-data → Data, 204/empty/404 → Empty, else → Error). -
PluginSource(#[async_trait], Tier-O object-safe per ADR-038 §1) —get/list/ asyncrender; the v1 backend isCompileTimePluginSourceover theCRAIG_PLUGINSlinkmeslice (v2 = WASM).PluginRegistry(pre-materialized, boot-validated) +PluginBootError. Noreqwest(the host fetches; the plugin is pure). See ADR-033.
craig-plugin-macros (Plan W Step 2, ADR-033)
-
CRAIG’s first first-party proc-macro (
[lib] proc-macro = true). The[craig_plugin(slug = "…", manifest = "…")]attribute annotates a plugin author’s sync-purefn(&RenderCtx) → Result<RenderedFragment, PluginError>and expands to a[linkme::distributed_slice(CRAIG_PLUGINS)]registration carrying the slug, theinclude_str!(concat!(CARGO_MANIFEST_DIR, "/", manifest))’d `Plugin.toml, and the render fn itself — so dispatch is registry-driven (registry.render(slug, ctx)), with no hardcodedmatch slug. -
The macro core operates over
proc_macro2types so the parse + codegen is unit-testable; an ident-safety guard rejects a non-kebab slug with a cleansyn::Error. Consumers needlinkmeas a direct dep (the expansion references::linkme) + acargo-macheteignore for it (macro-only use).
craig-plugin-example (Plan W Step 6, ADR-033) — under plugins/
-
The reference plugin and first
#[craig_plugin]consumer; the first member of theplugins/tree (NOTcrates/). A context-free dashboard panel: a sync render fn + a four-state Askama template (data/empty/error;loadingis shell-owned) + aPlugin.tomldeclaring one context-free craig endpoint (the open-cases count). CSP-clean (classes +data-*only). -
Opt-in: compiled into craig-web behind the
plugin-exampleCargo feature (NOT indefault)
the#[cfg(feature = "plugin-example")] use craig_plugin_example as _;linkmeforce-link inmain.rs; devstack/e2e enable it via theCRAIG_WEB_FEATURESDockerfile build-arg. This is the template every futureplugins/<name>(jurisdiction) plugin follows.
craig-composition-engine (Plan X Step 1, ADR-035)
-
The pure (NO-I/O) composition engine ported from canopy, adapted to CRAIG’s
BundleContribution/Claimsmodel. Consumed by theservices/craig-compositionhost (the I/O, DB, cache, and RMQ live there, not here). -
merge—apply_merge_patch_7396(RFC 7396) +apply_json_patch_6902(RFC 6902 viajson-patch): the layer-merge primitives for the 5-layer top-down walk. -
role_filter::filter_items_by_role— role filtering applied AFTER merge. -
user_delta— the slug-anchoreduser_delta_v1envelope (UserDelta/UserDeltaError)
apply_user_delta/validate_user_delta(incl. the CRAIG no-required-hidden floor). -
hash—canonicalize(RFC 8785 JCS) +composition_version(SHA-256 content hash for cache validation). -
defaults::system_defaults— the compiled EMPTY product-default surfaces. -
types—RawComposition/ComposedSurface/ComposedItem/ComposableSurface/DashboardLayout/CaseDetailShell/ShellSpec/CompositionKey+ the newtype slugsJurisdictionSlug/RoleSlug/ItemSlug.
#890 (epic &63): bounded_apply_json_patch_6902(doc, ops, &PatchBounds) is the resource-ceilinged RFC 6902 apply the composition loader uses (PatchBounds::DEFAULT = 256 ops / 4 MiB canonical result): over-long op lists refuse before any work, the patch runs on a scratch clone so the caller’s document mutates only on full success, and copy/move amplification hits the ResultTooLarge wall. Typed BoundedPatchError distinguishes resource refusals from RFC 6902 semantic failures.
craig-i18n (P3.1, ADR-044)
-
The shared Mozilla-Fluent i18n engine, extracted from craig-web’s former
i18n.rsso the BFF worker UI and the craig-intake edge (the public portal, Phase 3.2) consume ONE engine + ONE public catalog. Leaf-shaped: depends only oncraig-state-bundle(theTerminologyContributionoverlay type) + the Fluent stack (fluent-bundle/fluent-syntax/unic-langid) +axum(the middleware). -
I18n—load(disk{locales_dir}/{lang}/*.ftl+ the embedded public catalog + the jurisdiction terminology overlay, pre-resolved into alocale → key → Arc<str>table),translate(locale → default-locale → raw-key fallback), the encapsulating accessorshas_message/default_locale/available_locales(the storage map is private), and the consuming builderpin_negotiation_to_default(#1332 — restricts the negotiation set to the default locale; everynegotiate_localetier is membership-gated, so all request signals fall through to the default. craig-web’s worker-UI pin until real es worker catalogs exist, #1487; the messages table is untouched).TerminologyBootErroris the ADR-034 §8 bilingual-coverage boot gate. -
negotiate_locale— pure precedence resolver (?lang=query → caller candidates →Accept-Language→ default), shared by craig-web’s middleware (negotiation pinned per #1332; no session tier) and the edge. -
locale_layer— a session-less Axum middleware (readsExtension<I18n>) that negotiates + runs the request under thetask_local!locale scope;with_locale/translate_current/current_localeback the Askamat()filter. -
Embedded public catalog (
catalog/{en,es}/public.ftl,include_str!; bilingual key-parity since #722, pinned by a crate test) — the source of truth for thereport-/public-keys, folded into every load so a consumer with no on-disklocales/(the stateless edge, ADR-017) still resolves both product locales. Note: this injects anesbucket into every consumer — craig-web pins its worker-UI negotiation to the default locale for exactly that reason (#1332, ADR-044 amendment). -
test-utilfeature →I18n::for_testfor downstream unit tests needing a controlled catalog.
craig-state-bundle (Plan T1.5)
-
Bundle contract crate (ADR-032 A6 + §2.1 + ADR-038 §3/§4).
-
StateBundletrait (name/jurisdiction_code/contribute/federal_mapping+ the #1072 DEFAULTED pairsubsidy_policy()/uas_codes()) — the per-bundle-identity mappings are trait methods (the D3federal_mappingdoctrine), not contribution fields. -
BundleContribution8-field aggregate:adapters(factory pairs, A4) /audit_codecs(pre-materialized, A11) /mock_routes(A5) /partner_types/seed_data: SeedContribution
the Phase-2 axestheme: ThemeContribution(ADR-036) /terminology: TerminologyContribution(ADR-034) /compositions: CompositionContribution(ADR-035 §8); grows additively. -
Theme types (ADR-036 §2):
TokenPair,ModeTokens,ThemeBranding,Palette { light, dark, high_contrast: Option, branding },ThemeContribution { palette: Option<Palette> };simple_statehouse_palette()/product_default_palette(). Palette VALUES are generated fromtheme/*.tomlbybuild.rsat BUILD time (toml/serdeare build-deps only — no runtime toml leak). -
Theme RENDERER (ADR-036 §4 amendment, 2026-06-25 / #710): the pure
materialize_theme_css(&ThemeContribution) → Result<Arc<str>, ThemeBootError>+ThemeBootErrorlive here (the privaterender_*/validate_palettehelpers stay module-private). Shared by craig-web + craig-intake so both emit byte-identical CSS; each host resolves its own active bundle + holds its ownArc<str>(host-local materialization). The active-bundle resolver stays per-binary (it names concrete bundle crates → moving it would cycle). -
Terminology types (ADR-034 §3):
TerminologyCatalog { lang, source },TerminologyContribution { catalogs }. -
Seed types:
RateTableSpec { payment_type, age_min, age_max, daily_rate },SeedContribution { rate_tables }. -
Subsidy reference types (#1072):
SubsidyPolicySpec { first_month_proration: ProrationMethod, foster_home_approval_clock_days, err_qualifying_placement_types, guardianship_residence_months }(the #1069-U1 policy-seam lift — pure data; the derivations stay in craig-financial)
UasCodeSpec { code, label, program, tanf_reportable, cohort_closed, effective_from, effective_until }— the effective-dated UAS payment-classification vocabulary. Dates are ISO&'static str`s parsed fail-fast by the consumer at boot (the `RateTableSpec.daily_ratedoctrine — no chrono in the contract crate). Defaults (None/ empty) = the bundle contributes no subsidy reference data; consumers fail closed. -
BootContext { transport: Arc<dyn OutboundTransport> }(Plan V — swapped fromhttp_client). -
5 registries with duplicate-detecting
from_bundles→BundleMergeError::DuplicateEntry { kind, key }:AdapterRegistry/AuditCodecRegistry/PartnerTypeRegistry/FederalPartnerMappingRegistry/MockRouterRegistry. -
ErasedAdapterFactory = Arc<dyn Fn(&BootContext) → Arc<dyn ErasedAdapter> + Send + Sync>;MockRouteFactory = Arc<dyn Fn() → axum::Router + Send + Sync>. -
PRODUCT_PARTNER_TYPES: [&str; 14]— the neutral product partner-type taxonomy (single-sourced). -
resolve_active_bundle(candidate_names, requested) → Result<&'static str, ActivationError>(Plan U Step 7, ADR-032 §2.7) — pure strict activation fromCRAIG__ACTIVE_STATE_BUNDLES;ActivationError::{Missing, Unknown, Multiple}.
craig-state-ga (Plan T1.6; renamed from craig-state-default at Plan U Step 3)
-
The seed (Georgia) jurisdiction bundle (ADR-032 D6).
GeorgiaBundle—name() = "georgia",jurisdiction_code() = "georgia". -
Contributes: 10 typed adapter factories (SHINES deliberately absent — orchestrator-injected) + 10 audit codecs + the 14
partner_typetokens + 10mock_routes(under themockfeature)
seed_data(Georgia’s 5-row daily-rate schedule) + atheme(named Simple Statehouse palette under Georgia DHS branding) + aterminology(terminology/{en,es}/worker.ftl, flatterm-*Fluent messages viainclude_str!). -
federal_mapping(): 5 Phase-1 entries (tanf medicaid child_support education child_abuse_registry). -
#1072 subsidy reference:
subsidy_policy()= the GA 22.8 constants (calendar-day proration / 120-day approval clock / kinship-only ERR / 6-month residence floor) anduas_codes()= the 7-entry FY2011-vintage UAS vocabulary over 4 codes (err 542, sg 552, nrsg 550, ersg 552, enrsg 550, rcs 553, ercs 553 — enhanced variants ride their base family’s code, ⁂ #1073); TANF reportability keys on the code (542/552/553 yes, 550 no); closed-cohort flags pinned againstSubsidyProgram::open_for_enrollment. -
[features] mockfans out to every partner crate’smockfeature; consumed only bytools/craig-mock-server.
craig-state-tx-stub (Plan U Step 4)
-
The minimal Texas reference bundle — the second
StateBundle, proving jurisdiction-neutrality.TxStubBundle—name() = "tx-stub",jurisdiction_code() = "texas". Contributes zero adapters/codecs/mock-routes + the 14partner_typetokens + default (empty) theme/terminology/seed + no subsidy policy / UAS vocabulary (the #1072 trait defaults — craig-financial stays fail-closed under this bundle);federal_mapping()= 2 illustrative entries (tanfmedicaid). Deps: craig-state-bundle + craig-reference only.
craig-test-lib
-
TestConfig— env vars with devstack defaults (from_env(),token_url(),realm_url());KeycloakTokenProvider(per-username token caching, refresh within 30 s of expiry; #1163 adds a transparent cross-process file cache at the workspace root —.token-cache.json+ the.token-cache.mints.logmint counter, both gitignored/0600 — so a battery mints O(roles) ROPC grants instead of O(tests); invalidated automatically on devstack restart via the recordedtoken_url; any file failure degrades to the pre-#1163 per-process mint). -
shared_http_client()— areqwest::Clientfor integration tests (30 s timeout,pool_max_idle_per_host(4)), built once byTestHarness::new()and Arc-cloned across all `ServiceClient`s (one connection pool, not per-client). -
ServiceClient— generic Bearer-token HTTP (get/get_with_query/post/put/delete/get_raw/post_multipart/get_bytes);ApiResponse<T> { status, body: Option<T>, raw }. -
Typed clients (
RulesClient/CasesClient/PlacementClient/ExchangeClient/FinancialClient/ReportingClient/SecurityClient/IntakeClient) — all takereqwest::Clientas the first arg. -
TestHarness— full fixture sharing the connection pool:admin_*client()/supervisor*client()/caseworker*client()/readonly*client()/county_director*client()/regional_director*client()/state_office*_client(); built-in creds (admin/jane.doe/bob.smith/carol.reader/dana.county/rita.regional/sam.state); pinned UUIDsADMIN_SUB/SUPERVISOR_SUB/CASEWORKER_SUB/READONLY_SUB/COUNTY_DIRECTOR_SUB/REGIONAL_DIRECTOR_SUB/STATE_OFFICE_SUB(use these when filtering by worker). -
EventCollector(wait_for_event(predicate),events());devstack_available()(checks Keycloak + all/healthz); builders (rand_name(prefix),RuleSetBuilder,PersonBuilder,FosterHomeBuilder,PlacementBuilder). Gotcha: global-property tests race under nextest concurrency — sequence them in ONE test (Plan T2.4 lesson).spawn_for_test()fromtools/craig-mock-serveris the partner-mock harness used by theround_triptests. -
concurrent— theconcurrent_fire_*family (see the module doc’s when-to-use table)
wait_for_blocked_behind(pool, blocker_pid, timeout)(#1187): the pid-scoped deterministic wait for two-phase lock-interleave tests — pollspg_blocking_pidsuntil a backend parks behind YOUR transaction’s pid, returns a typedBlockedWaitError::Timeoutinstead of panicking. Never use a db-wide "any ungranted lock" poll: it false-triggers on sibling tests under the 8-wide integration profile (test protocol: the ADR-060 implementation plan § Tests). -
template_db(#1162) — migrated-template scratch databases:migration_set_fingerprint(&Migrator)(name suffix that changes exactly when the migration set does),ensure_template(admin, base, migrator)(idempotent, cross-process-safe via Postgres-native coordination, sweeps stale-fingerprint siblings),create_from_template(admin, new_db, template)(file-copy clone with a bounded 55006 retry). Replaces per-test full-migration replay in every migrated-scratch harness (cases keyed harness, security authz/archive harnesses, rules retention tests); per-test isolation + per-test field keys (ADR-048 §D6) unchanged. Full protocol: the module docs.
Client SDKs + browser library
-
TypeScript SDK —
@craig/intake-sdk(sdks/typescript/):IntakeClient(submitReport/checkStatus, JWS auto-sign),ReportBuilder,loadSigningConfig/canonicalize/signDetached,IntakeError. Depjose ^5; shared vectorssdks/test-vectors/canonical.json. -
Python SDK —
craig-intake-sdk(sdks/python/): asyncIntakeClient(httpx),ReportBuilder,load_signing_config/canonicalize/sign_detached,IntakeError. Depshttpx/joserfc/cryptography. -
Browser signing —
services/craig-web/static/js/craig-sign.js(~5 KB ES module, zero deps):generateKeyPair/exportKey/loadPrivateKey/canonicalize/signPayload(WebCrypto ECDSA P-256). All four implementations share the same canonicalization contract (craig-signing).