The Five Engineering Contracts

On this page

Context

CRAIG is multi-jurisdictional CCWIS built around composability as a first-class feature. Jurisdictions compose their own UI from their own endpoints for their own worker roles, shipped as config with no Rust changes. The five contracts on this page are the engineering surfaces that make that composability real — each one specifies what jurisdictions can author + what core renders + where validation happens.

These contracts emerged from the external design engagement (settled 2026-06-08) and are durable architectural reference. Their full implementation is sequenced under Plan S — Multi-Jurisdiction Foundation (umbrella; Plan S: Multi-Jurisdiction Foundation) and individual ADRs (ADR-032 + future ADRs per contract).

Contract 1: Plugin manifest + render contract

A panel or case-section — core or jurisdiction-authored — declares itself in a manifest. The runtime renders whatever the jurisdiction’s config references; nothing else appears.

What a plugin author writes

# plugins/<slug>/Plugin.toml

[plugin]
slug = "child_support"
exports = { panels = ["child_support"], case_sections = ["child_support"] }

[panel.child_support]
display_name = "{term.child_support}"   # terminology key, not a string
programs = ["tanf", "medicaid"]         # only renders for these case types
default_span = 4
allowed_spans = [4, 6, 8]

[data]
# the jurisdiction's OWN endpoint
source = "https://ocse.dhs.ga.gov/v1"
auth = "service_token"
cache_ttl = "5m"
timeout = "2s"

[permissions]
required_roles = ["eligibility_worker"]   # must exist in roles.toml
audit = "ocse_read"

states_required = ["data", "loading", "empty", "error"]   # PR-enforced

Core invariants

  • Plugins render server-side + are CSP-clean (no inline scripts; no unsafe-inline; no unsafe-eval). Aligns with the existing CRAIG strict-CSP posture documented at transport-security.adoc (Plan C F-018).

  • All four states requireddata / loading / empty / error. PR review rejects a plugin that doesn’t ship all four. See The Four-State Panel Contract.

  • Endpoint is the jurisdiction’s, not core’s. Plugins talk to per-jurisdiction service endpoints declared in [data]. Core does not proxy.

  • Permissions reference roles.tomlrequired_roles MUST exist in the jurisdiction’s roles registry; boot validation fails-fast on dangling references.

  • display_name is a terminology key, not a literal string. See contract 2.

Engineering touchpoints

  • Plugin loading + manifest validation: formalized by ADR-033; implemented by Plan W.

  • Plugin discovery + registry materialization: per ADR-033 the plugin axis is sourced through a source-agnostic PluginSource trait (v1 = a #[craig_plugin] + linkme compile-time slice; v2 = a WASM loader) and materialized BFF-local into a pre-materialized PluginRegistry, validated atomically at boot per the orchestrator pattern — NOT a plugins: Vec<PluginManifest> field on BundleContribution (ADR-033 §5; recorded as ADR-032 amendment A12).

  • Per-plugin endpoint outbound calls: route through OutboundTransport (ADR-032 §3.1 + Plan V).

Contract 2: Terminology dictionary

Every user-facing label in CRAIG is a key, not a literal string. The key resolves per-jurisdiction at render time. Georgia says "Family Services Specialist"; another jurisdiction says something else; a tribe reframes entirely.

What a jurisdiction author writes

# crates/craig-state-<jurisdiction>/terminology/en/worker.ftl
# embedded in the bundle crate via include_str! — NOT under craig-web.
# the locale dir is the primary-subtag bucket (en, es), matching the loader.

term-role-caseworker = Family Services Specialist
term-role-supervisor = Unit Supervisor
term-stage-investigation = Investigation
term-stage-ongoing = Ongoing services
term-my-day = My day
# the constituent catalog (terminology/en/constituent.ftl) may say "Family"
term-household = Household

# required: an `en` catalog + at least one Spanish (`es`) catalog per jurisdiction

Core invariants

  • Resolution falls back jurisdiction → product default → key-as-fallback. An unknown key renders as the key literal (never blank), so missing terms are caught in review.

  • Every jurisdiction MUST ship en-US + at least one es-US terminology catalog. Boot validation fails-fast on missing locales.

  • Constituent vs worker vocabularies are separate. The constituent catalog (Family + Foster portals) addresses the same data with a different vocabulary than the worker catalog ("household" → "family"; "case" → "your record").

  • Spanish copy is ~+20% longer than English; UI must accommodate without truncation.

  • RTL is not prevented — the layout grammar uses logical properties (margin-inline-start, padding-block-end) per CSS Containment-and-Logical-Properties baseline.

  • Keys are flat Fluent messages under a term- namespace (not Fluent terms): Contract-2 dotted keys map to message ids by replacing ./_ with - (role.caseworkerterm-role-caseworker). A jurisdiction overlay redefines term-* messages; see ADR-034 §6.

Engineering touchpoints

  • Terminology loads at boot from a pre-materialized terminology: TerminologyContribution (catalogs embedded in the bundle crate via include_str!); the craig-web BFF overlays the active bundle’s catalogs onto the product-default .ftl at boot. There is no standalone TerminologyRegistry — the existing I18n table is the materialized store (BFF-local; ADR-034 §3-§4).

  • Resolution at render: a term!() macro or t.lookup(key) call at template authoring time. Templates reference keys; runtime resolves.

  • i18n coverage for Spanish + future RTL: the existing craig-web Fluent stack extended to a per-jurisdiction overlay (craig-intake serves static HTML and is not a terminology consumer — ADR-034 §8).

Contract 3: Composition + persistence

Dashboards and case-detail surfaces are composed in config, not built in code. Layout resolves top-down across five layers, merging from most-specific to least-specific:

  1. User delta — small JSONB delta stored in DB, only what differs from below. Per-user personalization (drag-to-reorder, pin, hide).

  2. Role override — per-role overrides on top of jurisdiction config.

  3. Jurisdiction live override — DB-stored, edited via Studio admin UI. Quick jurisdiction-level changes that should take effect immediately without a deploy.

  4. Jurisdiction baseline — TOML files in rulesets/<jurisdiction>/, managed by ops via normal git operations. Edits land via PRs against the rulesets directory; no in-UI workflow.

  5. Product default — the baseline shape CRAIG ships.

The pattern mirrors what other config-as-data layers already do in CRAIG + matches canopy’s composability runtime model (5 layers, invalidate-on-write composition cache).

Separation of concerns — what each layer is for

Layer Lives where Who edits

Product default

Built into CRAIG; ships in the binary

Core engineering (PR against the NEW craig-composition service — ADR-035 homes resolution + the product default in that backend service, not the BFF)

Jurisdiction baseline

rulesets/<jurisdiction>/dashboards.toml (and case-detail, sign-in, etc.)

Ops team via normal git workflow — PR against the rulesets directory using whatever tooling they want. No in-UI baseline editing.

Jurisdiction live override

DB; written by Studio admin UI

Jurisdiction admin via Studio. Edits take effect on next render in the editing replica; multi-replica invalidation via composition.invalidated event on the existing RabbitMQ fanout.

Role override

rulesets/<jurisdiction>/roles/<role>.toml or DB live override scoped by role

Same as jurisdiction baseline / live (whichever store it sits in)

User delta

DB JSONB, composition_overrides rows scoped by user_sub (full schema in Engineering touchpoints; ADR-035 §5)

End-user via direct UI interaction (drag-to-reorder, pin, hide)

What a jurisdiction author writes (baseline TOML)

# rulesets/<jurisdiction>/dashboards.toml — jurisdiction baseline (git-managed)

[dashboard.my_day]
roles = ["caseworker"]
default_for_role = true

[[dashboard.my_day.row]]
panels = [
  { type="my_worklist", span=8, required=true },
  { type="response_clocks", span=4 },
]

What a user delta looks like (DB JSONB)

{
  "hidden": ["alerts"],
  "pinned": ["upcoming_court"],
  "rowOrder": [0, 2, 1]
}
The example above is the conceptual UI view (hide / pin / reorder). The serialized wire format is the versioned user_delta_v1 envelope specified in ADR-035 §3 (hidden_slugs / span_overrides / slug_order, slug-anchored, not array-index-anchored); ADR-035 is authoritative for the schema.

Core invariants

  • User deltas + live overrides are deltas, not copies. Each stores only what differs from the layer below; baseline changes flow through to all users / all sessions that haven’t overridden that specific field.

  • Baseline editing is ops territory, not UI territory. The UI does NOT handle merges, conflicts, PR creation, branching, or any git workflow. Ops updates baselines by editing files + opening PRs however they want (the file format is the contract; the workflow is operational).

  • required=true panels cannot be hidden by user deltas or live overrides. Core panels marked required (e.g. case spine on the case workspace) always render.

  • Layout grammar is row-based. Each row has a 12-column span budget; panels declare default_span + allowed_spans; total per row ≤ 12.

  • Role filter applies after merge. Items whose plugin manifest required_roles exclude the request’s role are silently dropped from the resolved tree — never rendered as "permission denied" tiles.

  • Resolved composition is hashable. The post-merge tree serializes canonically (RFC-8785-style key-sorted JSON) and SHA-256-hashes to a stable version number; the BFF carries that version for cache validation downstream.

Engineering touchpoints

  • Composition lives in a NEW backend service, not the BFF: ADR-035 homes resolution + the override store + the invalidation publisher in services/craig-composition (port 8009, DB craig_composition); craig-web becomes an HTTP client (it gains no DB/MQ deps). The portable engine is the NEW crates/craig-composition crate (a port of canopy’s composition crate; ADR-035 §1).

  • Composition surface declaration: extends BundleContribution with a pre-materialized compositions: CompositionContribution declaring the composable surfaces the bundle provides (ADR-035 §8 resolves ADR-032 A11 toward pre-materialized). The field does NOT carry baseline trees — the jurisdiction baseline (layer 4) is runtime-loaded by craig-composition from rulesets/<jurisdiction>/ (mirroring CRAIG’s existing JDM-rulesets loading), and only the jurisdiction-neutral product default is compiled in.

  • Live override + user delta storage: NEW table composition_overrides (jurisdiction_code TEXT, role TEXT, user_sub UUID NULL, surface_key TEXT, delta JSONB, updated_at TIMESTAMPTZ) keyed for role-scoped + user_sub-scoped lookups, on the craig-composition database (ADR-035 §5).

  • Resolution: a CompositionLoader (running in craig-composition) walks the five layers top-down — RFC 7396 merge for default→baseline, RFC 6902 op-lists for the DB override layers — role-filters after merge, and returns the resolved tree + its RFC 8785 + SHA-256 content hash (ADR-035 §3/§4/§6).

  • Invalidate-on-write cache: Studio writes (live override + user delta) invalidate the relevant (jurisdiction, role, surface) cache entry within the editing replica + publish a transactional-outbox composition.invalidated event so other replicas drop their cached entry on next render (ADR-035 §6 reuses the ADR-024 subscribe_exclusive fanout pattern, with a bounded 10-minute TTL as a delivery-failure safety net).

  • Auth pass-through: the BFF reaches craig-composition over the existing ADR-028 seam (its client_credentials token + an X-Craig-Actor JWT for the worker); no new IdP deps on the new service (ADR-035 §7).

  • Studio scope: edits live overrides (not baselines). Studio cannot create a PR, cannot edit rulesets/, cannot push to git. Baseline edits route through whatever ops workflow the jurisdiction uses against the rulesets directory.

Contract 4: Token + theme export

Color and type ship as data, not code. Two chosen palettes (Statehouse for worker, Foundation for household — see Brand Identity, Palettes, and Typography) map onto 20 semantic palette IDs. A new jurisdiction slots in by providing one theme.toml. Runtime emits CSS custom properties.

What a jurisdiction author writes

# crates/craig-state-<jurisdiction>/theme/theme.toml — embedded via include_str! in the bundle crate (NOT under rulesets/ or craig-web); brand layer only, rest inherited

[theme]
palette = "simple-statehouse"   # or any of the 8 Orchard palettes, or custom

[branding]
agency_name = "Georgia DHS"
logo_path = "brand/dfcs.svg"

Core invariants

  • A bundle MUST specify a palette — either one of the named CRAIG palettes (simple-statehouse, foundation, etc.) or a custom palette declaration with all 20 semantic IDs present.

  • Light + dark BOTH required. Bundles cannot ship light-only; the theme must declare both modes or inherit them from a named palette.

  • --accent is decoration only. Gold (Statehouse) / peach (Foundation) MUST NOT carry text contrast. WCAG 2.1 AA contrast applies to --primary, --ink, --page only.

  • Red is reserved exclusively for danger — never branding, never decoration, never delight. See Brand Identity, Palettes, and Typography § color principles.

  • --ink is the body-text token — the WCAG-AA text color audited against --page (named in the contrast triplet above but not previously defined). See ADR-036 §7.

Engineering touchpoints

  • Theme loading at boot: extends BundleContribution with a pre-materialized theme: ThemeContribution (a typed Palette parsed BY THE BUNDLE CRATE from its embedded theme.toml; craig-web does not parse TOML). See ADR-036 §2-§3.

  • CSS custom property emission: craig-web materializes the active bundle’s palette into a :root { --primary: <hex>; … } string at boot (BFF-local; the generated CSS string IS the registry, no standalone ThemeRegistry) and serves it from a same-origin GET /assets/theme.css route — required because the strict CSP (style-src 'self') forbids inline <style>. See ADR-036 §1/§4.

  • Light/dark coverage validation: build-time lint scans templates for hardcoded hex values that should be tokenized.

Contract 5: Field ownership + authz

The CWCA provider portal (and any future multi-tenant edit surface) needs an explicit ownership map. Each field is one of:

  • State-owned — provider reads; state worker writes

  • Provider-owned — provider writes; state worker reads

  • Shared — both can read; provider can propose edits; state worker writes

Example ownership table — CWCA provider portal

Field Owner CWCA Provider State Worker

Placement status

State

read

read / write

Permanency goal

State

read

read / write

Monthly service record

Provider

read / write

read

Provider contact log

Provider

read / write

read

Child demographics

Shared

read / propose

read / write

Core invariants

  • Field-level authz is enforced at the OWNING backend service (the service that owns the surface’s live data, e.g. craig-cases for case-detail fields), not the BFF and not the UI — see ADR-037 §1 (this refines the original "enforced at the BFF" sketch; the BFF reaches the backend over the ADR-028 actor seam and cannot itself enforce). The lock icon in the UI is a reflection of the backend’s decision, not the source of truth.

  • Per-role per-jurisdiction enforcement. The ownership table is jurisdiction-scoped (different states may slot fields differently); enforcement at request time consults the active jurisdiction’s ownership map.

  • "Propose" is a write-shaped action that produces an approval queue entry, not a direct write. The proposing-role’s write lands in pending_edits_<surface> on the owning backend; the owning-role accepts/rejects from a queue.

  • Audit is mandatory. Every read emits an audit event (role + jurisdiction + field; a read has no before/after) AND every write (including propose+approve) emits one (role + jurisdiction + field + before/after) — see ADR-037 §6.

Engineering touchpoints

  • Ownership-table loading: extends BundleContribution with a pre-materialized field_ownership: FieldOwnershipContribution declaration; the per-jurisdiction owner map is runtime-loaded by the owning backend from rulesets/<jurisdiction>/cwca_ownership.toml (ADR-037 §4, the ADR-035 §8 declaration-in-bundle / data-on-disk split; refines ADR-032 A11 via Amendment A13). The BundleContribution axis lands in Phase 11 with the CWCA provider portal (its consumer); Plan Y ships the surface-agnostic craig-authz field-permission capability the portal calls (ADR-037 §2/§3).

  • Per-request enforcement: builds on the existing zen-engine authz DSL (ADR-023) extended with field-level granularity — realized as craig-authz::resolve_field_permission (Plan Y); the per-surface owner map + propose-approve queue are Phase-11 (the portal).

  • Propose-approve queue: pending_edits_<surface> table + accept/reject API on the OWNING backend service (ADR-037 §6, refining the original "NEW BFF surface + table"); the BFF hosts only the non-authoritative "Pending changes" view in Studio whose accept/reject actions call the backend API (Phase 11, on the portal’s owning backend).

Where these contracts land in code

All five contracts extend the same BundleContribution aggregate that ADR-032 §2.1 introduces for partner adapters. The trait grows fields without losing its shape:

pub struct BundleContribution {
    // Plan S §1 — partner adapter dispatch
    pub adapters: Vec<(&'static str, Arc<dyn ErasedAdapter>)>,
    pub audit_codecs: Vec<(&'static str, Arc<dyn AuditCodec>)>,
    pub mock_routes: Vec<(&'static str, MockRouteFactory)>,
    pub partner_types: Vec<PartnerTypeMeta>,
    pub seed_data: SeedContribution,

    // Design contract 1 — plugin manifest: NOT a field. The plugin axis is sourced
    // via the source-agnostic PluginSource trait (v1 linkme slice / v2 WASM loader)
    // and materialized BFF-local into a PluginRegistry (ADR-033 §5; ADR-032 A12).

    // Design contract 2 — terminology
    pub terminology: TerminologyContribution,

    // Design contract 3 — composition + persistence
    pub compositions: CompositionContribution,

    // Design contract 4 — token + theme
    pub theme: ThemeContribution,

    // Design contract 5 — field ownership
    pub field_ownership: FieldOwnershipContribution,
}

The boot orchestrator validates uniqueness + completeness across all fields atomically per ADR-032 §2.1. No contract ships independently of the others; a bundle that declares partners but no terminology fails boot validation.

The detailed per-contract implementation sequencing lives in Plan S — Multi-Jurisdiction Foundation (umbrella) and its child plans. Each contract gets its own ADR formalizing the trait shape + invariants (anticipated: ADR-033 plugin manifest; ADR-034 terminology; ADR-035 composition; ADR-036 theme; ADR-037 field ownership).

Source + provenance

Edit this page · latest