Coding Conventions (Canopy)

On this page
NOTE

The universal Rust coding conventions — style core principles (async/sync, dead code, libraries-over-reimplementation, composition, size/complexity ceilings), the error philosophy (no unwrap/expect/panic, typed errors, no Box<dyn Error>), concurrency primitives, formatting & linting, SPDX headers, plan authoring & lifecycle, the canonical Status vocabulary, changelog format, ADR format, dependency management, and the [workspace.lints] table — live in the synced standard at Coding Conventions (universal standard). Do not duplicate them here.

This page is canopy’s project-specific overlay: the conventions and overrides that are genuinely particular to this codebase and have no home in the universal standard. Where the authoritative detail already lives in an ADR or a plan, this page is a cheat-sheet that xrefs it rather than restating it.

Askama 0.15 templating quirks

canopy-web uses askama = "0.15". The following quirks bite where Askama-0.14 web examples mislead. Each was hit during the worker-portal redesign (Stage 4 / Plan 4).

  • {% call macro() %} requires {% endcall %}. In 0.15 call is a block, not a self-closing tag: write {% call idp_icons::icon_shield() %}{% endcall %}. A missing {% endcall %} first errors expected 'endcall' to terminate 'call' node, then the parser spills into the next sibling tag and emits misleading unknown node 'lse' / unknown node 'lif' errors (it has eaten the e of a downstream else/elif). If you see lse/lif, look upstream for a call missing its endcall — it is not an else/elif problem.

  • No method calls in templates. {{ entry.field.to_string() }} does not expand. Precompute view-model strings in the handler (e.g. a ChipView { label, color, icon, href } struct) and interpolate the plain field: {{ chip.color }}.

  • No {% match %} block. {% match x %}{% when … %}{% endmatch %} does not parse. Branch with nested {% if %} on a precomputed String discriminator. {% elif %} is supported (see templates/appeals/list.html).

  • Auto-escaping emits NUMERIC entities, not named. The .html escaper produces < / > / " / ' / & (not </>/…). Escaping unit tests must assert the numeric form (html.contains("<b>")), not the named form. Escaping applies in attribute position too, so interpolated values are XSS-safe without manual escaping.

  • Option<String> binds via .as_ref(). Use {% if let Some(x) = field.as_ref() %}{{ x }}{% endif %}. The bare {% if let Some(ref x) = field %} is rejected because ref is a Rust 2024 reserved keyword.

CI/CD runners (DHS self-hosted)

  • All jobs must use DHS self-hosted runners — no GitLab shared runners (saas-linux-*).

  • Every job must have an explicit tags: key — never inherit a default or leave it unset.

  • Runner sizes:

    Tag Use

    dhs-aws-autoscaler-docker.small

    Lightweight jobs: linting, auditing, doc checks, hash comparisons

    dhs-aws-autoscaler-docker.medium

    Compilation jobs: fmt + clippy + nextest, release builds, cross-compilation

    dhs-aws-autoscaler-docker.large

    Heavy jobs: Docker-in-Docker builds, E2E test suites, corpus regression tests

    dhs-aws-autoscaler-docker.xlarge

    Multi-service Docker builds, full integration suites

    dhs-aws-autoscaler-docker.2xlarge

    Parallel multi-arch builds, load testing

  • When adding a new CI job, choose the smallest runner that can complete it within a reasonable time.

  • The .rust-job shared template in .gitlab-ci.yml does not set a default tag — each job must set its own.

HTTP / API: 201-Created create-endpoint override

IMPORTANT

All create endpoints return 201 Created, not 200 — a canopy-specific override of the universal HTTP/API default of 200. Required for correct HTTP semantics in FFE account-transfer integrations. Use Result<(StatusCode, Json<T>), ApiError> from the handler.

The other HTTP/API rules (idempotency, shared reqwest::Client, RFC 9457 Problem Details, pre-1.0 vs post-1.0 contract stability) are universal — see the standard.

Secure-by-default environment behavior

When CANOPY_ENV is unset, all services default to production behavior (restrictive). Verbose diagnostics, permissive startup, and development shortcuts require explicitly setting CANOPY_ENV=development or CANOPY_ENV=test. This prevents a misconfigured production deployment from leaking internal state or accepting unencrypted PII.

Framework patterns (Axum 0.8)

Handler return type

All handlers return Result<Json<T>, ApiError> (or Result<(StatusCode, Json<T>), ApiError> for the 201-Created create case above).

async fn get_person(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<Person>, ApiError> {
    let person = persons::get(state.db.inner(), id)
        .await?
        .ok_or_else(|| ApiError::NotFound(format!("person {id}")))?;
    Ok(Json(person))
}

Error variants (canopy_common::error::ApiError)

Variant HTTP status When to use

BadRequest(String)

400

Invalid input, validation failure

Unauthorized

401

Missing or invalid JWT

Forbidden

403

Valid JWT but insufficient role

NotFound(String)

404

Resource not found

Conflict(String)

409

Duplicate key, version conflict

Internal(String)

500

Database error, unexpected failure

All error responses serialize as the RFC 9457 ProblemDetails struct from canopy_common::error. From<sqlx::Error> is implemented — use ? directly on database queries; the impl logs the real error server-side and returns a generic client message.

State, signing, rules-as-data

  • State management: AppState { db, auth } via State<AppState>. Service-specific state via Extension<T> layers.

  • Signing: the canopy_signing::DeterminationSigner trait in crates/canopy-signing/src/traits.rs. Program services implement it to produce detached JWS signatures (ADR-002).

  • Rules-as-data (ADR-003): eligibility logic lives in JDM rulesets (rulesets/{jurisdiction}/.json), NOT in Rust. Rust assembles the input, calls canopy_rules_client::RulesClient::evaluate(), and parses the output. Federal parameters load from rulesets/federal/.json at startup.

Database patterns

  • Engine: PostgreSQL (sqlx 0.8, compile-time-verified queries).

  • Soft-delete: active BOOLEAN NOT NULL DEFAULT true column; list queries filter WHERE active = true.

  • Primary keys: UUID v7 via Uuid::now_v7() (time-ordered, globally unique). All entity IDs use the typed newtype wrappers from canopy_common::id (PersonId, HouseholdId, ApplicationId, …) generated by the define_id! macro — distinct types prevent ID-mixing bugs at compile time. Generic/polymorphic references (e.g. the rules-engine context_id) may use raw uuid::Uuid.

  • Pagination: canopy_common::pagination::PageRequest (page + per_page). Max 500 per page.

  • Connection pool: default 10 connections, 600 s idle timeout (configurable via env vars); shared per service, never PgPool::connect() per request.

  • Program isolation (ADR-001): each program service has its own PostgreSQL instance. No cross-database queries. Program-service migrations run against that service’s own postgres (e.g. services/canopy-snap/migrations/postgres-snap:5433); infrastructure-service migrations run against the shared postgres:5432. Never put a migration in the wrong service.

  • Forward-only migrations (ADR-016): every migration ships as up.sql only. If a migration is wrong, the fix is a new forward migration that corrects it. Destructive changes follow expand-contract — separate forward migrations to add the new shape, backfill, then drop the old. Migrations are additive-only in production (CREATE TABLE, ALTER TABLE ADD COLUMN, CREATE INDEX are safe; never DROP/TRUNCATE/DELETE FROM). Each service runs sqlx::migrate!("./migrations") at boot — since #1246 over a dedicated short-lived connection that closes before the app pool exists, and skippable per service via SKIP_MIGRATIONS once the deploy-time job (cargo xtask migrate apply --service <svc>) owns the schema (the #1279 cutover posture for the chain services). Multiple replicas are safe (advisory locks). Dev rollback is cargo xtask migrate snapshot + migrate rollback; production rollback is PITR.

  • Least-privilege services — the per-migration ownership-transfer convention (#1456, ADR-004 A8b): in a service running the owner/app role split (canopy-reporting today), every new migration that creates a table or function must end with ALTER … OWNER TO canopy_<svc>_owner plus the app-role grants its runtime SQL needs. Objects default to migrator-owned, which the restricted runtime login cannot reach — skipping the transfer fails loud in the devstack battery (the runtime IS the restricted login) but only AFTER the migration merges. The one-time catalog-loop in reporting’s 20261111000000 covered pre-cutover objects only; it is not a recurring sweep. Full runbook: security-operations › Reporting Credential-Cutover.

  • canopy-web mutation authorization (#1516, ADR-044): a new BFF mutation handler must (1) classify itself in SCOPE_POLICY (xtask/src/cmd/route_authz.rs) — RequireExtractor when the program is a compile-time fact (take ProgramScope<Tag> in the signature; the sealed tags live in program_scope), RequireAuthorizedWrite when the set comes from the resource (construct an AuthorizedResource via all_of/any_of(_slugs) from the fetched row, never from a form field), or NotProgramScoped(reason); and (2) obtain its write-capable clients through clients.authorized(&authz) — the InternalClient write verbs are module-private, so there is no other way to POST/PUT/DELETE upstream. Empty/unparseable authoritative sets fail closed (422). An unlisted mutation fails cargo xtask validate (route-authz scope pass).

Canopy-specific conventions (cheat-sheet)

These are the rules with no universal equivalent. Where an ADR owns the detail, that ADR is authoritative.

Convention Rule (authoritative detail → ADR)

Event-bus data restrictions

Never publish restricted federal data to canopy.events. Events may carry IDs, status codes, timestamps, program codes, and non-restricted metadata only. FTI, SSA SOLQ/BINDEX, IEVS, and HIPAA-scoped fields must never appear in event payloads. canopy-security’s wildcard # subscriber captures all events — this is intentional and must not be worked around. (ADR-004)

FTI audit logging (canopy-tanf, canopy-medicaid)

FTI access logs to fti_audit_log directly — not via the event bus — and is held separately from the application audit log for independent IRS Pub 1075 audit. Hash-chain integrity: SHA-256 previous_hash/event_hash columns, advisory-lock-serialized inserts; a chain break emits fti.audit_chain.breach_detected and forces 503 from the chain-status endpoint. (ADR-014)

Determination objects

Append-only. Once signed and accepted they are never modified in place — only superseded by a new determination from a new evaluation. (ADR-002)

Session storage

MemoryStore is banned. BFF services use tower-sessions-sqlx-store backed by PostgreSQL (with a Redis LRU cache). The applicant portal (canopy-portal) is the documented exception — Postgres-free, Redis-primary opaque-token sessions. (ADR-009, ADR-026)

Encrypted secrets at rest

Secrets are SOPS-encrypted YAML in secrets/dev.yaml (canopy repo, fake values only) + per-jurisdiction private deployment-config repos. Encryption is age (X25519 + ChaCha20-Poly1305) via SOPS for value-level diff-friendly encryption. Plaintext secrets in checked-in YAML are forbidden — secret-loading reads via EnvSecretProvider after SOPS decryption at deploy time / cargo xtask dev start. (ADR-017)

Integration tests

Run against the local devstack (cargo xtask dev start). Use canopy_test_lib::infrastructure_available() to skip gracefully when devstack is down; in CI (CANOPY_CI=true) this panics instead of skipping — tests must never silently pass in CI. Nextest concurrency is capped to num-cpus to avoid overwhelming the shared devstack.

Iterator first-match footgun

Iterator::find_map / find returns only the first match — a silent footgun when iterating a homogeneous collection (JDM decision-table nodes, repeated record types) to act on a specific target: it no-ops against the wrong element with no error. Use an explicit counter + nth-match (or filter(…​).nth(k)) when the match is positional, not value-unique.

Adding a new service

Checklist for a new service (e.g. canopy-childcare):

  1. Cargo workspace: create services/canopy-childcare/ with Cargo.toml (workspace member), src/main.rs, src/api/mod.rs; add to the root workspace members list.

  2. Port allocation: choose the next available port (check the port map in Local Development); add it to the map.

  3. Database: add CREATE DATABASE canopy_childcare; to devstack/postgres/init.sql (infrastructure) or create a new postgres container (program service, per ADR-001).

  4. Migrations: create services/canopy-childcare/migrations/ with the initial schema.

  5. Docker Compose: add a service entry with healthcheck, port, database URL, dependencies.

  6. Boilerplate: wire canopy_api::ApiServer with healthz, metrics, RBAC middleware, RabbitMQ publisher.

  7. OpenAPI: add the #[derive(OpenApi)] ApiDoc struct with utoipa annotations; register at /swagger-ui.

  8. Events: src/events.rs with service-specific publishers (IDs only, no PII).

  9. SPDX + safety: // SPDX-License-Identifier: AGPL-3.0-or-later in every .rs; #![forbid(unsafe_code)] in src/main.rs.

  10. Tests: tests/{service}_test.rs with an infrastructure guard + at least a healthz test.

  11. Docs: author the canonical Antora pages — api/canopy-{service}.adoc (endpoints), data-models/canopy-{service}.adoc (tables), and a capability block + topology row in the Service Catalog (the canonical per-service status home). Update the local-dev.adoc port map and architecture if the database topology changes.

Adding a new jurisdiction

Checklist for onboarding a jurisdiction (e.g. texas) — see also the Jurisdiction Onboarding Runbook:

  1. rulesets/{jurisdiction}/ — create the directory.

  2. jurisdiction.toml — state FIPS, SNAP gross/net income limits by household size, asset limits, standard deductions, SUA amounts, BBCE policy, ABAWD waiver areas, medical-expense thresholds. Use rulesets/georgia/jurisdiction.toml as the reference. Every value needs a citations.toml entry (ADR-011).

  3. JDM rulesets — at minimum for SNAP: snap-eligibility.json, snap-deductions.json, snap-disqualification-screening.json. Copy Georgia’s and adjust thresholds/policy.

  4. Federal rulesetsrulesets/federal/*.json are shared across jurisdictions (FPL, allotments, income limits); no change needed unless federal parameters differ (rare).

  5. Typst notice templates + componentsrulesets/{jurisdiction}/notices/snap/ and …/notices/components/ with jurisdiction-specific letterhead, addresses, legal citations, hearing-rights phone numbers. Orchard components are jurisdiction-agnostic.

  6. Themerulesets/{jurisdiction}/theme.toml for BFF sidebar branding.

  7. Signing keyscargo xtask gen-signing-keys --program snap for the new key pair under .keys/.

  8. Env vars — set CANOPY_{SERVICE}__JURISDICTION={jurisdiction} for all services.

  9. Validationcargo xtask dev start, cargo xtask seed --jurisdiction {jurisdiction}, then verify notice-PDF generation and the eligibility determination flow end-to-end.

Worker-portal & composition patterns

These patterns govern canopy-web’s composability runtime (epic &51, ADR-021 + ADR-022). The detail lives in the redesign plans (Worker Portal Redesign); the load-bearing rules:

  • Primitive vs utility class. Reach for an Orchard primitive ({% call o::panel_frame(…​) %}, o::status_pill, o::big_number, …, in templates/_primitives/orchard.html) when you need structure — primitives carry semantics (data-* attributes, ARIA hooks) and own their chrome. Reach for a utility class (u-mb-6, u-grid-stats, …) for stateless layout adjustment on top of a primitive or plain element. Body-slot macros (panel_frame, overline, hero_strip, status_pill) use {% call %}{% endcall %}; inline macros (big_number, money_cell, gold_rule, leaf_glyph) use {{ o::macro(…​) }}.

  • CSP discipline. Never emit inline style= attributes from any primitive or consumer template — canopy-web ships strict CSP style-src 'self' (services/canopy-web/src/csp.rs). All sizing is via discrete class variants; primitives_test.rs pins this (smoke_emits_no_inline_style_attributes).

  • Four-state panel convention. Every panel renders all four states — empty, loading, populated, error — using the panel-state primitives (o::empty_state, o::skeleton, o::skeleton_row, o::error_block). Every [panels.] / [case_sections.] in a Plugin.toml MUST declare required_states = ["empty", "loading", "error", "populated"]; the manifest validator rejects anything outside the four-state set.

  • Plugin authoring. Declare Plugin.toml per the ADR-021 schema; attach #[canopy_plugin(slug="…​", manifest="…​")] to the handler struct. The macro emits a linkme::distributed_slice entry that CompileTimePluginSource walks at startup. manifest = "…​" MUST be a crate-relative path (the macro include_str!`s `concat!(env!("CARGO_MANIFEST_DIR"), "/", manifest)). Call dashboard::panels::assert_registered() at startup so the linker retains each plugin’s static under dead-code elimination.

  • Loader validation order. Manifest pre-validation → surface-aware export resolution (UnknownPlugin on miss) → role filter (silent drop) → span + row constraints. Span/row run after the role filter so dropped-for-role panels don’t trigger spurious overflows. The cache invalidates on every override mutation (v1 single-replica; multi-replica RabbitMQ fanout deferred post-UAT).

  • /v1/composition JSON API. RFC 7232 preconditions on PUT (If-Match replace / If-None-Match: create-only; 428/400 on absent/both); PATCH validates Content-Type: application/json-patch+json *before body deserialization (415 vs 400); errors use the closed-set { "error": { "code", "message", "details" } } envelope with sqlx/serde/canopy_mq errors sanitized (logged server-side, never leaked). The sub-router carries an Extension<Arc<CompositionState>> layer (NOT Router<CompositionState>) so it merges into Router<AppState>. CSRF on these JSON routes relies on the session cookie’s SameSite=Strict attribute (set in main.rs with a load-bearing comment) — do NOT downgrade to Lax/None without first adding a CSRF-token middleware. JSON extractors (JsonAuthenticatedWorker, JurisdictionAdmin) share the HTML BFF’s refresh-token + fail-closed semantics via resolve_worker_or_fail in services/canopy-web/src/session.rs — never bypass that helper; token refresh is a security property.

  • Composition audit emission. Composition mutations and audit="read" panel renders emit JWS-signed AuditEvent`s per ADR-014. Renders go through `Arc<dyn AuditEmitter> (AmqpAuditEmitter, best-effort fire-and-warn). Mutation audit does NOT — handlers emit inline via publisher.publish_tx(&mut tx, &envelope) so the outbox row commits in the same Postgres transaction as the row mutation (atomic chain integrity).

Pre-commit Q1–Q8 checklist

The .githooks/pre-commit hook runs an 8-question challenge/response checklist (the token rotates each attempt). AI agents must spawn an Explore subagent to verify Q1–Q8 against the staged diff. Address all eight before committing:

  1. Have you written any code that needs tests? If so, write them.

  2. Have you used any hacks or bypasses? If so, undo them and implement correctly.

  3. Have you weakened any tests? If so, undo the weakening and fix the broken functionality.

  4. Have you deviated from the plan? If so: update the plan’s Design/Scope to reflect what was built AND file a GitLab issue for any follow-up. Errata is for genuine post-hoc corrections (typos, citation errors), not a dumping ground for "I built it differently." See ADR-013.

  5. Have you updated GitLab issues/epics/milestones? If no, do so.

  6. Can this feature be improved? If so, file a GitLab issue and link it. Plans are specifications, not backlogs — do NOT add ideas to plan "Potential Improvements" sections. See ADR-013.

  7. Does any documentation need updating? If so, do the update.

  8. Have you left any TODOs or stubs not tracked in a GitLab issue? If so, create an issue.

The other enforcement layers (commit-msg type prefixes, the pre-push cargo xtask validate step list, the CI SAST/secret/dependency scans) are universal — see the standard.

Quality-budgets gate

Canopy enforces a strict exceed-craig lint posture plus a monotonic quality-budgets debt ratchet (ADR-030). cargo xtask quality-budgets counts code-quality debt metrics (route-module LOC, function LOC, untyped serde_json::Value, #[allow] attributes, unwrap_or_default, duplicate dep versions, untyped test-client methods, ambient decision-clock reads) and ratchets each to a monotonic floor in xtask/quality-budgets.lock. cargo xtask validate runs --fail-on-regression (blocking, also a CI job): grandfathered debt can only shrink.

The full procedure — lock authority semantics, lowering the floor (--write-lock), the constrained rules for raising it, and the B1–B8 metric definitions — lives in the Code-Quality Gating plan. Do not duplicate it here.

Edit this page · default