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

  • ApiError enum — 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) and From<anyhow::Error> (internal anyhow paths). New code should prefer typed thiserror errors + a From<TypedError> for ApiError impl over routing through anyhow.

    • BadRequest + Conflict carry a type_url: Option<&'static str> populated by bad_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::Mutex for short sync critical sections (no poisoning); tokio::sync::Mutex when the lock crosses .await; std::sync::Mutex/RwLock are forbidden in CRAIG-authored code (prefer parking_lot).

  • Id = uuid::Uuid; new_id() generates UUID v7.

  • PageRequest { page: u32, per_page: u32 }offset() / limit() (clamped to MAX_PER_PAGE = 500); derives Deserialize + IntoParams. PageResponse<T> { data: Vec<T>, page, per_page, total: i64 } — derives Serialize + ToSchema.

  • ServiceSettings — loaded from CRAIG_<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); Debug redacts database_url + rabbitmq_url. Defaults: log_level=info, jurisdiction=georgia, cors_origins="" (same-origin), body_limit=2 MiB, max_connections=10, idle_timeout=600 s.

  • supervisor module (#1229 — moved from craig-api, which re-exports every name): the ADR-061 worker supervision + liveness registry (Supervisor, WorkerHealth, shutdown_signal, the WorkerCheckEntry healthz wire mapper). Lives HERE so the DB/MQ-less craig-web BFF can supervise its workers without sqlx/lapin; panic text comes from craig_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 via tracing-subscriber.

  • rate_limit::RetainingIpLimiter (#1136) — per-IP keyed governor limiter with amortized key retention: every 4096th allow() 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-exports governor::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 on ServiceSettings (subsidy_review_sweep, nests as CRAIG_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_types gains SWEEP_ALREADY_RUNNING + SWEEP_RUN_STATE.

  • SubsidyErrSettings (#1069 M2) / SubsidySgSettings (#1070, ADR-056) — the enrollment consent knobs on ServiceSettings (fields subsidy_err / subsidy_sg, nesting as CRAIG_FINANCIALSUBSIDY_ERR* / CRAIG_FINANCIALSUBSIDY_SG*; both { enabled: bool }, default OFF — enabling is the operator’s recorded consent to the ⁂ #1073 money-policy readings). ONE subsidy_sg knob covers the sg AND nrsg family and gates the create arms
    the witnessed activation ONLY — existing agreements' lifecycle (generator, ALL transitions incl. the corrective guardianship_finalized, reviews, sweep) is deliberately grandfathered. problem_types gains CLOSED_PROGRAM (#1070 — closed-cohort creation refusals naming the closure date + the #1071 import path).

  • SubsidyImportSettings (#1071, ADR-057 D12) — the conversion-import gate on ServiceSettings (field subsidy_import, nesting as CRAIG_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: georgiaAmerica/New_York, unmapped → UTC), today(), date_of(DateTime<Utc>), timezone_name(). Copy; resolve today() ONCE per unit of work and thread the NaiveDate through 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_hash are OIDC id-token-only claims (#813); JwksProvider verifies typ against a configurable expected value (default "Bearer"; the craig-web id-token verifier sets "ID" via with_token_type).

  • RealmAccess { roles: Vec<String> }.

craig-authz (ADR-023 / ADR-024)

  • AuthzEngine trait (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 surfacing AuthzError so craig-rules' bootstrap fallback can distinguish source-attested PolicyMissing (the ONLY admitting class) from a real deny or a runtime fault (incl. the #786 PolicyLoad refresh-fault variant → 500); the defaults report an opaque non-admitting deny (test doubles inherit unchanged), and the typed list seam surfaces a miss as Err(PolicyMissing) where the untyped path keeps Ok(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 (ZenAuthzEngine builds the input via build_check_input(…, Action::Update) + injects field/field_owner into resource.attrs) read through a NEW disjoint field_permission output token, so check/auto_scope_list are untouched.

  • ZenAuthzEngine — concrete impl over zen_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, computes ResourceType × Jurisdiction coverage. 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> }.

  • Action enum — Read | List | Create | Update | Delete | Approve (snake_case).

  • ResourceType enum — one variant per protected resource across every service (snake_case, IntoEnumIterator for the boot coverage check; the enum in craig-authz/src/types.rs is the source of truth).

  • ListScopeAll | AssignedWorker(Uuid) | AssignedSupervisor(Uuid) | Custom(Value) | Denied.

  • FieldPermissionRead | Write | Propose | None (ADR-037 §3; the per-field analogue of ListScope; Propose = a non-owner’s effective permission on a shared field under Action::Update).

  • CoverageWarning::Missing { jurisdiction, missing } — boot signal; bails when CRAIG_<SVC>__AUTHZ_REQUIRE_FULL_COVERAGE=true.

  • RulesetSource trait + InMemoryRulesetSource (tests) + craig_rules_client::RulesClient (prod). RulesetCache = HashMap<name, CachedRuleset> behind tokio::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 is craig_bootstrap::OutboxAuditSink (one-shot outbox tx, fire-and-forget, warn!-only failures). NoopAuditSink (explicit discard) + RecordingAuditSink (test assertions) ship alongside. The engine stages authz.cache_miss on every source-attested miss (attributed: sub/is_service/service_id; never on PolicyLoad faults) and authz.cache_refreshed on 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_refreshed envelope 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) → check Forbidden (PolicyMissing), auto_scope_list ListScope::Denied (untyped path; the typed seam surfaces Err(PolicyMissing)), resolve_field_permission FieldPermission::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(…​) — binds ruleset.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). Implements craig_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), inserts Claims into extensions); auth_middleware() Tower fn; require_role(claims, role) → Result<(), Response>.

craig-db

  • DbPool — wraps sqlx::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 via verify_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-time db_pool_connections_* gauges) and ProbeSample { wait, error } from probe_acquire — one timed acquire-and-release; PoolTimedOut is the session-exhaustion signal, catching the server-ceiling case (size < max while 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 s acquire_timeout remains 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_gate gives 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_migrations executes 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-019 after_connect timeout hook fires only on new physical connections, so a migration’s plain session-scoped SET (e.g. SET statement_timeout = 0) would otherwise serve traffic for up to max_lifetime. Convention for migration authors: use SET LOCAL for per-migration overrides so one migration’s setting cannot bleed into later migrations in the same run (cargo xtask validate-migration-constraints reports plain SET statement_timeout sites).

  • Schema state machine (ADR-063, #1306, schema_state module): the ONE classifier both process modes consume. Pure core classify(&[AppliedRecord], &[EmbeddedMigration]) → SchemaState (Dirty | ChecksumMismatch | Diverged | Behind | Ahead | Exact, deterministic precedence; Behind ⇒ applied ⊊ embedded by construction)
    embedded_up_migrations(&Migrator) (down-files filtered, matching sqlx apply semantics). Consumers on DbPool: verify_schema(migrator, FloorCheck, gate_hint) → Result<SchemaVerified, SchemaVerifyError> (serving boot: plain-SELECT inspection, no DDL, no advisory lock, SELECT-only-role safe; Ahead tolerated only while the schema_compat_floor table’s min_required_version ≤ max(embedded)) and apply_by_verdict(migrator) → Result<ApplyOutcome, SchemaApplyError> (migration gate: Exact/Ahead no-op, Behind applies via run_migrations, everything else refuses). Only SQLSTATE 42P01 maps to Behind-from-zero; every other DB error passes through typed. The schema_compat_floor singleton 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).

  • Subscribernew(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 by AMQP_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 set basic_qos prefetch 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_error stamp
    InboxError::HandlerPanicked), counted by dlq_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: 'static on all subscribe/inbox handler bounds (pre-1.0 breaking, #1203). Every queue is declared x-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 at error! ("operator action required") while the supervisor keeps retrying.

  • stage_event(tx, envelope) → Result<(), StageError> — transactional-outbox staging (ADR-022 §D3.1): serialize + INSERT the envelope in the caller’s tx. Returns the typed craig_mq_error::StageError (re-exported as craig_mq::StageError); see craig-mq-error.

  • subscribe_dlq(queue, routing_keys, shutdown, config, handler) — DLQ subscription; the handler takes a typed DeadLetterDelivery (#1181): envelope + dlq.-stripped original_queue + the derived per-occurrence identity (valid _park capture first — carried verbatim through the parking hop, #1197 — then x-death, then the validated _dlx wrapper, else tokenless) behind occurrence_token()/occurred_at()/park_count() accessors, and returns Result<(), 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). config is a validated DlqRetryConfig { 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 #1199 dlq_queue_depth gauges for the family queues (metrics::dlq; craig-security records dlq_outcomes_total via the re-exported DeadLetterOutcome). OccurrenceToken is a bounded validated newtype; publish_dlx takes 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 is for<'t> FnOnce(EventEnvelope, &'t mut Transaction<'static, Postgres>) → BoxFuture<'t, Result<(), E>> (craig_mq::BoxFuture is 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 5s only after the lock, a 15 s attempt deadline. Failure accounting rolls back TO a savepoint so the claim row survives as the accounting row and INBOX_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 in event_inbox_collisions with a dlxcol: token and nacks without requeue — the canonical row is never touched. The terminal DLX surface is mandatory-published from durable row state (occurred_at := stored received_at — re-surfaces byte-identical); a failed/unroutable surface leaves failed_at NULL and nacks requeue-TRUE (the source-queue copy is the durable envelope). Callers propagate InboxError to subscribe unmapped — the subscriber’s settlement keys on its Display. consumer_queue (#1196, epic &75 C1) stays the subscription’s own queue name: the surface routes under dlq.<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 the CRAIG_<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 info craig-crypto/blind-index/<domain>/v<version>; takes bytes, so Date canonicalization works; no cross-column HMAC correlation). The global-domain hmac() 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 through craig_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 (capability module): 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 one const table (substring ⇒ Plaintext+Text; Opaque ⇒ nothing). FieldSpec::blind_index(enc, &BindValue) is THE single blind-index derivation (canonicalize per the registry spec, then hmac_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 are const fn`s that `assert! the required capability, so an illegal scheme/operation pairing is a compile-time error.

  • Planner (plan, with entity + runtime): SearchEntity (entity — the descriptor trait binding a service DTO as Filters) + plan(…​) → SearchPlan { table, where_sql, binds, order_by, order_dir }. Injection-safe by construction (SQL from registry &'static literals
    $N only; user values reach only BindValue binds), WHERE TRUE base, bound NULL-guards for absent filters, LIKE metacharacters escaped (ESCAPE '\'), fail-closed (UnsupportedFilter/UnenforceablePolicy error, never widen). In runtime: ScopeConstraints (None/Worker/Supervisor) — the separate non-degradable policy channel (unconditional equality, no NULL-guard); SearchMode { Optional, Required } mirroring EncryptionMode without the craig-common dep; BindValue. SearchRuntime (plan) carries the optional FieldEncryptor + mode.

  • Executor (exec, feature sqlx): 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: None renders the legacy SELECT *; Some renders the validated column list (#1158 — heavy columns never fetched; T decodes the projected shape). List SQL itself renders via the sqlx-free SearchPlan::list_sql(projection).

  • Read projection (plan, #1158): Projection wraps &'static [&'static FieldSpec]; Projection::new validates 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-ALL test.

  • Write/decrypt walk (row): EncryptableRow + FieldRef (Text/TextRequired/Jsonb)
    the impl_encryptable_row! declarative column→field table macro (single-sibling hmac: "col" ⇒ field or the braced multi-sibling table — #1064 added the latter when persons gained a second BlindIndex pair); encrypt_row/decrypt_row walk encrypted_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 (RowSlotKindMismatch on shape disagreement). A registry-encrypted column the shape does not expose is a hard RowMissingColumn/RowMissingHmacSlot (fail closed and loud, never a silent plaintext write); JSONB uses the {"v": "<ct>"} envelope; a BlindIndex sibling is derived from the plaintext via FieldSpec::blind_index before 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 ApiError lives in craig-common behind its search feature (UnsupportedFilter → generic 400 with no field oracle; server-side faults → redacted 500; ADR-047 layering precedent).

  • validate_registry / RegistryError — runtime well-formedness the const layer cannot cover: dangling/mistyped _hmac siblings, duplicate columns, duplicate blind-index domains (cross-column correlation), invalid domains, out-of-envelope blind-index bases (UnsupportedBlindIndexBase). Called from each registry’s registry_is_well_formed test.

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<()> — drains event_outbox rows (staged by domain handlers in-tx) to RabbitMQ; watches shutdown.

  • init_object_store() → Result<Store> — loads ObjectStoreConfig + opens the Store (local FS in tests, Garage in devstack, S3 in prod).

  • build_shared_http_client(pkg_name, pkg_version) → Result<reqwest::Client> — wraps craig_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 former mq_health layer — MQ health rides craig_api::AppState.mq as MqRequirement::Required so 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; picks RulesClient::with_service_token vs 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: the WorkerHealth registry on which the boot registers authz-invalidation
    authz-ttl-refresh as Critical workers, plus — #1228 — the authz-eval liveness sentinel (Critical) watching the dedicated !Send-eval OS thread via ZenAuthzEngine::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 to ZenAuthzEngine::boot_degraded, an empty cache over the REAL source (#786 — the former empty-InMemoryRulesetSource substitution falsely attested universal absence and never self-healed; with require_full_coverage a 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; the Idempotency-Key header is no longer honored anywhere; the orphaned idempotency_responses tables were DROPPED ×8 in BF #1270). Each service prunes its request_claims table hourly via the request-claims-retention worker (craig_mq::spawn_local_sweep, advisory lock "CRAIGRCL", 5k batches) on the deployment-global CRAIGREQUEST_CLAIMSWINDOW_DAYS horizon (default 30; 0 ⇒ registered Disabled (info log); an unparseable value also disables, with an ERROR log — never a guessed horizon; the request_claims_retention_overrun watchdog is the second indicator). Newtypes ClientRequestId / ClaimScope / EntityKind / IntentHash (grammar v1:<64 hex>, constructible only via intent_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, money rescale(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 locking INSERT … ON CONFLICT DO UPDATE … RETURNING with xmax-based insert detection) → Claimed | Replay { entity_id } | 409 IDEMPOTENCY_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 table request_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; no Option, no Default — omission is a compile error), workers: WorkerHealth (#1186 / ADR-061).

  • Supervisor (supervisor module, #1186): install() (creates THE process shutdown token
    registers the signal-bridge), token(), expect(&[WorkerSpec]), health() → WorkerHealth (the registry handle: watch / watch_optional / watch_liveness / mark_disabled / snapshot), drain(WORKER_DRAIN_DEADLINE), check_exit(). Mains follow serve(…​).await?; supervisor.drain(..).await; supervisor.check_exit()?;. #1229: the module (with shutdown_signal and the WorkerCheckEntry wire mapper) LIVES in craig_common::supervisor so the DB/MQ-less BFF can supervise its workers; every craig_api path above is a re-export and stays valid.

  • ServerOptions { cors_origins: String, body_limit: usize } (default 2 MiB).

  • ApiServerrouter(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 bounded HTTP_DRAIN_DEADLINE (20s) HTTP drain, delegating to the test seam serve_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 primitives connect_database / init_auth / connect_mq (for non-standard compositions; craig-intake’s integrated mode formerly used them and is now MQ-less — a connect_mq caller composing its own channels gets connection-only readiness gating until it attaches a publisher channel, #1235); shutdown_signal() (SIGTERM/SIGINT / Ctrl-C — the former shutdown_token() free-token helper is retired by #1186; use Supervisor::install()). Since #1226/#1227 the JWKS refresh loop is token-aware, and since #1236 the #1160 pool acquire-wait probe is too — both ride BootstrapResult.background (BackgroundWorkers: one shared shutdown token + named handles) to adopt_background_workers(&supervisor, br.background.take()) — registered Observed (jwks-refresh + db-pool-probe in every service’s worker set) with the supervisor’s shutdown bridged in once; OidcDiscovery::start_refresh_task carries 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 halves bootstrap_data_plane(prefix, service_name) → DataPlane { settings, db, _telemetry }bootstrap_control_plane(prefix, service_name, DataPlane). The 8 stateful mains boot through bootstrap_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::SchemaVerify carries the remedy-first refusal.

  • Migrate process mode (#1307, ADR-063 D2/D4, migrate_mode.rs re-exported): run_migrate_mode_if_requested(env_var, service, &Migrator) → Result<bool, MigrateModeError> — the <binary> migrate gate. Exact argv shape (migrate alone; --print-openapi together is a typed conflict; non-Unicode argv is a typed error); called BEFORE print_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 core run_migrate_gate(url, service, &Migrator) → ApplyOutcome builds a minimal 1-connection pool (recorded deviation: DbPool::connect_with, not raw PgConnection — reuses the tested apply_by_verdict path; intent kept: no metrics, no probe) and applies by verdict. Since M3a the 8 mains call run_process_modes_if_requested(service, &Migrator, &OpenApi) → Result<bool, ProcessModeError> — the ONE home of the mode-ordering contract (migrate first, --print-openapi second) and of the CRAIG_<SVC>DATABASE_URL name derivation.

  • multipart module (#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

  • ObjectStoreConfigCRAIG_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(); Debug redacts 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 transitions mark_stored/finalize_attempt (promote-lock-first + AttemptFinalizer effects + 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 ignores If-None-Match BY DESIGN, see the garage_put_create canary) + 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). The upload_attempts table 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 the SUBSIDY_*_REASON_CODES consts + 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 canonical SubsidyProgramFamily::ALL order every inter-family lock taker shares, so create-vs-import stays deadlock-free by construction). PartnerType was deleted at Plan T3.3 (partner_type is an open snake_case token validated by PartnerTypeRegistry membership).

  • fips module — State enum (50 + DC + 5 territories, USPS abbreviation); AdminUnit { name, fips_code, state, unit_type }; admin_units_for_state(state); State::fips_code().

  • translate module — admin_unit_to_fips / full_fips_code; *_str_to_afcars / *_str_to_ncands.

  • federal_partner_category (Plan T1.4) — FederalPartnerCategory 9-variant federal-closed enum (tanf ccwis afcars ncands iv_e icpc medicaid education_slds child_support); consumed by StateBundle::federal_mapping() + FederalPartnerMappingRegistry; no sqlx::Type.

  • afcars / ncands modules — gender/race/ethnicity/permanency/placement/abuse/reporter/disposition conversions (+ reverse). The ncands module 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, returning None where CRAIG has no faithful code per ADR-040; added #644) and the legacy *_to_ncands short mnemonics (a CRAIG-internal vocabulary, never emitted to a federal file). validation module — 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 serde try_from/into), plus the MAX_ATTACHMENTS count const (5). Backend-agnostic so the IntakeSink seam 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).

  • NameSimilarity trait (score(a, b) → f32 in [0,1] + algorithm_name()); TrigramJaccard impl (strsim-backed). v1: phone_match is hardcoded false (no phone column 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 to ApiError.

  • SsnDigest (1430, plan Plan::SSA SD12) — the versioned SSN blind-index digest newtype. Canonical wire form is one string v{version}:{44-char base64} (serde try_from/into String; the version is the per-field blind-index rotation version, stamped by the producing boundary). PartialEq IS the constant-time compare (subtle::ct_eq over the full canonical bytes; no Hash/Ord); Debug redacts the digest (version only) and there is deliberately no Display; SsnDigestError is categorical (never echoes input). Wire-only in A4 — no sqlx::Type (B1 decides the column shape); no ToSchema (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_digest stores as_str() verbatim under a mirroring grammar CHECK.

  • craig-exchange-transport::EgressControlledTransport (#1465, plan Plan::SSA B4 / S13) — the hardened egress the typed SSA worker sends through (the generic DirectHttpTransport is untouched): https-only with a literal-loopback http dev carve-out, exact-host allowlist (empty = deny-all; EgressPolicy::from_hosts takes 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 as BadStatus, and the 1 MiB chunked-read response byte cap (MAX_RESPONSE_BYTES — the A1-deferred cap). check_egress is 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, plan Plan::SSA B2) — the value-bound custody-release wire (ADR-065 §D2): SsnReleaseRequest.expected_digest is the FIRST production SsnDigest consumer; SsnReleasePurpose is 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, plan Plan::SSA B3) — the screening request-path wire: the cases intent body (RequestCaseScreeningBody), the staging request/response (StageScreeningRunRequest/StageScreeningRunResponse, members carrying SsnDigest), housed HERE (not craig-exchange-contracts, a deliberately pristine leaf) because the staging wire IS the screening cohort in motion; both services consume it. requested_by/requested_by_name are stage-time attribution asserted by the allowlisted cases service (the §D2 posture).

  • screening_cohort (#1462, plan Plan::SSA B1 / 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 framed uuid \0 digest \n — the ADR-057 manifest_checksum precedent; 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) and screening_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-lib RulesClient so the request wire shapes stay single-sourced. A leaf crate (serde / serde_json / uuid / utoipa only; never depends on services/* or craig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). The content / input fields carry zen-engine-polymorphic JDM as serde_json::Value. Response types (store::RuleSet, store::RuleEvaluation) remain in craig-rules — they are sqlx::FromRow store 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-lib ReportingClient so the request wire shapes stay single-sourced. A leaf crate (serde / utoipa / garde / craig-validation only; never depends on services/* or craig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). The garde field-length validation derives move with the DTOs (so reporting_period stays CODE_MAX-capped). Response types (store::models::{AfcarsSubmission, NcandsSubmission, …​}) remain in craig-reporting — they are sqlx::FromRow store 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-lib Financial*Client family so the request wire shapes stay single-sourced. A leaf crate (serde / utoipa / garde / craig-validation + the field-type crates uuid / rust_decimal / chrono; never depends on services/* or craig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). The garde field-length validation derives move with the DTOs (the handlers' body.validate()? calls behave identically). rust_decimal::Decimal carries the workspace serde-str feature, 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 is Option, and agreement_id/term_id mark subsidy-origin UNIT-MONTH rows — daily_rate = gross = the monthly award); the remaining response types (store::models::{RateTable, PaymentAdjustment, ClaimingRecord}) stay in craig-financial as sqlx::FromRow store models (the broader response-typing follow-up). subsidy_generation (#1068/ADR-053) adds the generation-trigger pair: GenerateSubsidyPaymentsRequest with the strict canonical YearMonth token (one parser, property-tested: never-panics, byte-identical roundtrip, exact grammar) and the per-run GenerationReport counters both the endpoint and the financial.subsidy_generation_completed event carry (#1070 F2 adds the serde-defaulted skipped_before_payments_begin — months skipped before a guardianship agreement’s stored money boundary; #1071 D5 adds the serde-defaulted skipped_before_cutover — months before an imported agreement’s payment_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 of POST /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/Nrsg share CreateGuardianshipAgreementBody (the two-step PENDING mint: signed_at + the anchoring placement_id, the U3 per-program relationship-evidence rule — required non-blank for sg, optional for nrsg — the required caregiver_assessment_evidence_key, guardianship-family-only predecessor_agreement_id lineage; the F8 canonical hash covers the TAGGED body), and the four closed cohorts (Ersg/Enrsg/ Rcs/Ercs) share the empty CreateClosedProgramBody the service refuses with the typed closed-program problem. CreateSubsidyPartyRequest (1–10 signatories, ≥ 1 caregiver, 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_id is 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 of POST /v1/financial/subsidy-agreements/{id}/activate) carries NO dates beyond the optional past-dated tanf_terminated_on attestation — transfer_on is DERIVED from the anchoring placement’s S2S-projected end date, never caller-typed; fields: the CAS expected_head_interval_id, the required court_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. SubsidyAgreementDetail gains 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 recorded ImportStampRecord — the D9 posture means NO server facts and NO authz denorms exist on the DTO), ImportWarning/ImportBlocker/PaymentAssessment (the persisted stage outcomes), StageOutcome + the StageOutcomeKind replay vocabulary (staged/already_staged/restaged/already_imported/rejected), ImportRecordView/ ImportBatchDetail/ImportBatchCounts/ImportRecordPage, and FinalizeReport/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, null optionals, ISO dates, RFC 3339 UTC stamps, money normalized to 2 dp so "450.5""450.50"), canonical_hash (sha256 hex of the compact serialization), and manifest_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-only SweepMovedChild (per diem on a DIFFERENT placement — visibility, never executed) join the preview/run views; ExecuteSweepRequest grows the acknowledged_suspend/acknowledged_terminate/acknowledged_handoff handshake (server 409s on any mismatch with the pinned sets — a UI that never rendered a leg cannot execute it); SweepReport and SweepRunCounters grow leg3_actionable/handoff_terminated/leg3_moved_children, and SweepRunView grows mode_per_diem_handoff — all M3 additions serde(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 + the events::PLACEMENT_ACTIVATED routing-key constant are compiled by BOTH the craig-placement producer and the craig-financial consumer, so the billing contract (field names, foster_home_id nullability, started_at as the proration anchor) cannot drift the way the untyped placement.created shapes did pre-#979. A leaf crate (serde + uuid / chrono; never depends on services/* or craig-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 + the events::PLACEMENT_ENDED routing-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-side json! whose end_reason the financial consumer silently dropped, making the two sides undriftable). end_reason is Option for 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/status wire tokens, started_on), the response of craig-placement’s GET /v1/placement/placements/{id}/subsidy-eligibility and the type craig-financial’s PlacementClient S2S 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_on when end_reason = guardianship; None while the placement is live, and absence from an older producer means "not ended", so the F7 witnesses refuse), end_reason, and permanency_goal (the activation witness refuses reunification — the non-reunification-order proxy, ⁂ #1073). started_on/ended_on are the placement’s first/last day on the JURISDICTION’s business calendar (Eastern for Georgia), projected placement-side via BusinessClock::date_of so the consumer never converts timestamps. Type/status stay snake_case strings — the crate is a serde-only leaf; consumers parse into craig_reference enums 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, and signer_keys modules (admin-unit / major-change / detection-rule / NIST-control / review / partner / partner-key / signer-key create+update bodies) — used by the service and the test-lib Security*Client family so the request wire shapes stay single-sourced. A leaf crate (serde / serde_json / utoipa / garde / craig-validation + chrono; never depends on services/ or craig-test-lib, avoiding the dependency cycle per the test-framework-hardening §D8.2 contracts pattern). The garde field-length validation derives move with the DTOs. The polymorphic findings / remediation_plan / public_key_jwk fields stay JSON values (STRUCTURAL-VALUE carve-outs); VerifyPartnerRequest stays service-internal (the test-lib verify_partner builds its body internally). Response types (store::models::) remain in craig-security — they are sqlx::FromRow store 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 of POST /public/v1/reports (and the partner submit path). A leaf crate (serde / serde_json / chrono / utoipa / garde / craig-validation; no uuid/craig-reference — the fields are plain String/bool/JSON values capped by garde, not domain-enum-typed; never depends on services/* or craig-test-lib, per the test-framework-hardening §D8.2 contracts pattern). The garde field-length validation derives move with the DTO, so the handler’s body.validate()? is unchanged; the polymorphic children / adults / narrative fields stay JSON values (STRUCTURAL-VALUE carve-outs), so the struct derives PartialEq but not Eq.

  • Why single-sourced (the decision note for #637): before #637 the report request shape existed three times — the service’s SubmitReportRequest, the craig-web BFF’s hand-built serde_json::json! envelope coupled to it only by a // matches SubmitReportRequest comment, and the SDK’s ReportSubmission. craig-web now constructs SubmitReportRequest directly, 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 `ReportSubmission builder 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-lib IntakeClient speak the wire shape as external callers (the IntakeClient stays raw Value, #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’s signing module 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-Signature when 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).

  • signing module — SigningConfig (from_jwk(jwk_json, key_id), key_id()), canonicalize_json, hash_payload, sign_detached (shares the craig-signing contract).

craig-exchange-contracts (Plan L + Plan T1.2)

  • Typed ExchangeAdapter trait (RPITIT; ADR-038 §1 Tier-T) — unchanged by the erased seam.

  • ExchangeAdapterKind enum DELETED at Plan T3.1 (ADR-032 §1.3 open-TEXT carriage) — adapter identity is the &'static str from ErasedAdapter::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 *value methods return BoxFuture<', Result<…, ErasedAdapterError>> and thread format: Option<&str>.

  • DispatchClass (#1428, exhaustive by design) — Generic (the generic exchange pipeline may stage/dispatch Value payloads for the kind) vs TypedOnly (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 (both Generic)
    typed_only (#1428 — send_value/audit_value refuse unconditionally with TypedOnlyDispatch; 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 through Arc<dyn OutboundTransport>).

  • Value-objects (public fields): TransportRequest { endpoint, body: Vec<u8>, headers: Vec<(String,String)>, timeout }; ProbeRequest { endpoint, timeout, method: ProbeMethod } (Head partners / Get SHINES); TransportResponse { status: u16, body: Vec<u8> }.

  • TransportError (thiserror, Clone) — Timeout / Connect / BadStatus { status, body } / Transport(String); reqwest is never named in a pub signature.

  • 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 (behind test-util — canned-response, request-recording double); pub use futures_util::future::BoxFuture.

craig-partner-audit (Plan L + Plan T1.3)

  • PartnerAuditEvent discriminated union (11 Georgia partner variants since #1429 — the SsaSolq placeholder payload joined at A3; closed-aggregator per ADR-032 A2) + decode_jsonb(kind, …) / PartnerAuditDecodeError::UnsupportedKind { kind } — unknown/foreign tokens fail closed.

  • AuditCodec trait (impls live here per the orphan rule) — encode(&self, payload: &Value) → Result<PartnerAuditEvent, ErasedAdapterError>. 10 <X>AuditCodec unit 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_trip tests; they route through craig-exchange-transport and register via the active StateBundle.

  • craig-partner-ssa-solq (1427, plan Plan::SSA A1) — the 11th partner crate and first DispatchClass::TypedOnly member; UNREGISTERED until A3. Two departures from the family template: the CLOSED categorical SsaSolqError (no upstream bytes in Display — 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 the typed_only erased 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 (typed Plugin.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 / async render; the v1 backend is CompileTimePluginSource over the CRAIG_PLUGINS linkme slice (v2 = WASM). PluginRegistry (pre-materialized, boot-validated) + PluginBootError. No reqwest (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-pure fn(&RenderCtx) → Result<RenderedFragment, PluginError> and expands to a [linkme::distributed_slice(CRAIG_PLUGINS)] registration carrying the slug, the include_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 hardcoded match slug.

  • The macro core operates over proc_macro2 types so the parse + codegen is unit-testable; an ident-safety guard rejects a non-kebab slug with a clean syn::Error. Consumers need linkme as a direct dep (the expansion references ::linkme) + a cargo-machete ignore 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 the plugins/ tree (NOT crates/). A context-free dashboard panel: a sync render fn + a four-state Askama template (data/empty/error; loading is shell-owned) + a Plugin.toml declaring one context-free craig endpoint (the open-cases count). CSP-clean (classes + data-* only).

  • Opt-in: compiled into craig-web behind the plugin-example Cargo feature (NOT in default)
    the #[cfg(feature = "plugin-example")] use craig_plugin_example as _; linkme force-link in main.rs; devstack/e2e enable it via the CRAIG_WEB_FEATURES Dockerfile build-arg. This is the template every future plugins/<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 / Claims model. Consumed by the services/craig-composition host (the I/O, DB, cache, and RMQ live there, not here).

  • mergeapply_merge_patch_7396 (RFC 7396) + apply_json_patch_6902 (RFC 6902 via json-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-anchored user_delta_v1 envelope (UserDelta / UserDeltaError)
    apply_user_delta / validate_user_delta (incl. the CRAIG no-required-hidden floor).

  • hashcanonicalize (RFC 8785 JCS) + composition_version (SHA-256 content hash for cache validation).

  • defaults::system_defaults — the compiled EMPTY product-default surfaces.

  • typesRawComposition / ComposedSurface / ComposedItem / ComposableSurface / DashboardLayout / CaseDetailShell / ShellSpec / CompositionKey + the newtype slugs JurisdictionSlug / 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.rs so 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 on craig-state-bundle (the TerminologyContribution overlay type) + the Fluent stack (fluent-bundle/fluent-syntax/unic-langid) + axum (the middleware).

  • I18nload (disk {locales_dir}/{lang}/*.ftl + the embedded public catalog + the jurisdiction terminology overlay, pre-resolved into a locale → key → Arc<str> table), translate (locale → default-locale → raw-key fallback), the encapsulating accessors has_message / default_locale / available_locales (the storage map is private), and the consuming builder pin_negotiation_to_default (#1332 — restricts the negotiation set to the default locale; every negotiate_locale tier 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). TerminologyBootError is 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 (reads Extension<I18n>) that negotiates + runs the request under the task_local! locale scope; with_locale / translate_current / current_locale back the Askama t() 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 the report-/public- keys, folded into every load so a consumer with no on-disk locales/ (the stateless edge, ADR-017) still resolves both product locales. Note: this injects an es bucket into every consumer — craig-web pins its worker-UI negotiation to the default locale for exactly that reason (#1332, ADR-044 amendment).

  • test-util feature → I18n::for_test for 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).

  • StateBundle trait (name / jurisdiction_code / contribute / federal_mapping + the #1072 DEFAULTED pair subsidy_policy() / uas_codes()) — the per-bundle-identity mappings are trait methods (the D3 federal_mapping doctrine), not contribution fields.

  • BundleContribution 8-field aggregate: adapters (factory pairs, A4) / audit_codecs (pre-materialized, A11) / mock_routes (A5) / partner_types / seed_data: SeedContribution
    the Phase-2 axes theme: 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 from theme/*.toml by build.rs at BUILD time (toml/serde are 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> + ThemeBootError live here (the private render_*/validate_palette helpers stay module-private). Shared by craig-web + craig-intake so both emit byte-identical CSS; each host resolves its own active bundle + holds its own Arc<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_rate doctrine — 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 from http_client).

  • 5 registries with duplicate-detecting from_bundlesBundleMergeError::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 from CRAIG__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). GeorgiaBundlename() = "georgia", jurisdiction_code() = "georgia".

  • Contributes: 10 typed adapter factories (SHINES deliberately absent — orchestrator-injected) + 10 audit codecs + the 14 partner_type tokens + 10 mock_routes (under the mock feature)
    seed_data (Georgia’s 5-row daily-rate schedule) + a theme (named Simple Statehouse palette under Georgia DHS branding) + a terminology (terminology/{en,es}/worker.ftl, flat term-* Fluent messages via include_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) and uas_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 against SubsidyProgram::open_for_enrollment.

  • [features] mock fans out to every partner crate’s mock feature; consumed only by tools/craig-mock-server.

craig-state-tx-stub (Plan U Step 4)

  • The minimal Texas reference bundle — the second StateBundle, proving jurisdiction-neutrality. TxStubBundlename() = "tx-stub", jurisdiction_code() = "texas". Contributes zero adapters/codecs/mock-routes + the 14 partner_type tokens + 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 (tanf medicaid). 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.log mint counter, both gitignored/0600 — so a battery mints O(roles) ROPC grants instead of O(tests); invalidated automatically on devstack restart via the recorded token_url; any file failure degrades to the pre-#1163 per-process mint).

  • shared_http_client() — a reqwest::Client for integration tests (30 s timeout, pool_max_idle_per_host(4)), built once by TestHarness::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 take reqwest::Client as 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 UUIDs ADMIN_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() from tools/craig-mock-server is the partner-mock harness used by the round_trip tests.

  • concurrent — the concurrent_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 — polls pg_blocking_pids until a backend parks behind YOUR transaction’s pid, returns a typed BlockedWaitError::Timeout instead 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. Dep jose ^5; shared vectors sdks/test-vectors/canonical.json.

  • Python SDK — craig-intake-sdk (sdks/python/): async IntakeClient (httpx), ReportBuilder, load_signing_config / canonicalize / sign_detached, IntakeError. Deps httpx / 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).

Edit this page · latest