Coding Conventions (Canopy)
On this page
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.15callis a block, not a self-closing tag: write{% call idp_icons::icon_shield() %}{% endcall %}. A missing{% endcall %}first errorsexpected 'endcall' to terminate 'call' node, then the parser spills into the next sibling tag and emits misleadingunknown node 'lse'/unknown node 'lif'errors (it has eaten theeof a downstreamelse/elif). If you seelse/lif, look upstream for acallmissing itsendcall— it is not anelse/elifproblem. -
No method calls in templates.
{{ entry.field.to_string() }}does not expand. Precompute view-model strings in the handler (e.g. aChipView { 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 precomputedStringdiscriminator.{% elif %}is supported (seetemplates/appeals/list.html). -
Auto-escaping emits NUMERIC entities, not named. The
.htmlescaper 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 becauserefis 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.smallLightweight jobs: linting, auditing, doc checks, hash comparisons
dhs-aws-autoscaler-docker.mediumCompilation jobs: fmt + clippy + nextest, release builds, cross-compilation
dhs-aws-autoscaler-docker.largeHeavy jobs: Docker-in-Docker builds, E2E test suites, corpus regression tests
dhs-aws-autoscaler-docker.xlargeMulti-service Docker builds, full integration suites
dhs-aws-autoscaler-docker.2xlargeParallel 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-jobshared template in.gitlab-ci.ymldoes not set a default tag — each job must set its own.
HTTP / API: 201-Created create-endpoint override
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 |
|---|---|---|
|
400 |
Invalid input, validation failure |
|
401 |
Missing or invalid JWT |
|
403 |
Valid JWT but insufficient role |
|
404 |
Resource not found |
|
409 |
Duplicate key, version conflict |
|
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 }viaState<AppState>. Service-specific state viaExtension<T>layers. -
Signing: the
canopy_signing::DeterminationSignertrait incrates/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, callscanopy_rules_client::RulesClient::evaluate(), and parses the output. Federal parameters load fromrulesets/federal/.jsonat startup.
Database patterns
-
Engine: PostgreSQL (sqlx 0.8, compile-time-verified queries).
-
Soft-delete:
active BOOLEAN NOT NULL DEFAULT truecolumn; list queries filterWHERE active = true. -
Primary keys: UUID v7 via
Uuid::now_v7()(time-ordered, globally unique). All entity IDs use the typed newtype wrappers fromcanopy_common::id(PersonId,HouseholdId,ApplicationId, …) generated by thedefine_id!macro — distinct types prevent ID-mixing bugs at compile time. Generic/polymorphic references (e.g. the rules-enginecontext_id) may use rawuuid::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 sharedpostgres:5432. Never put a migration in the wrong service. -
Forward-only migrations (ADR-016): every migration ships as
up.sqlonly. 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 INDEXare safe; neverDROP/TRUNCATE/DELETE FROM). Each service runssqlx::migrate!("./migrations")at boot — since #1246 over a dedicated short-lived connection that closes before the app pool exists, and skippable per service viaSKIP_MIGRATIONSonce 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 iscargo 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>_ownerplus 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’s20261111000000covered 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) —RequireExtractorwhen the program is a compile-time fact (takeProgramScope<Tag>in the signature; the sealed tags live inprogram_scope),RequireAuthorizedWritewhen the set comes from the resource (construct anAuthorizedResourceviaall_of/any_of(_slugs) from the fetched row, never from a form field), orNotProgramScoped(reason); and (2) obtain its write-capable clients throughclients.authorized(&authz)— theInternalClientwrite 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 failscargo 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 |
FTI audit logging (canopy-tanf, canopy-medicaid) |
FTI access logs to |
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 |
|
Encrypted secrets at rest |
Secrets are SOPS-encrypted YAML in |
Integration tests |
Run against the local devstack ( |
Iterator first-match footgun |
|
Adding a new service
Checklist for a new service (e.g. canopy-childcare):
-
Cargo workspace: create
services/canopy-childcare/withCargo.toml(workspace member),src/main.rs,src/api/mod.rs; add to the root workspace members list. -
Port allocation: choose the next available port (check the port map in Local Development); add it to the map.
-
Database: add
CREATE DATABASE canopy_childcare;todevstack/postgres/init.sql(infrastructure) or create a new postgres container (program service, per ADR-001). -
Migrations: create
services/canopy-childcare/migrations/with the initial schema. -
Docker Compose: add a service entry with healthcheck, port, database URL, dependencies.
-
Boilerplate: wire
canopy_api::ApiServerwith healthz, metrics, RBAC middleware, RabbitMQ publisher. -
OpenAPI: add the
#[derive(OpenApi)]ApiDocstruct with utoipa annotations; register at/swagger-ui. -
Events:
src/events.rswith service-specific publishers (IDs only, no PII). -
SPDX + safety:
// SPDX-License-Identifier: AGPL-3.0-or-laterin every.rs;#![forbid(unsafe_code)]insrc/main.rs. -
Tests:
tests/{service}_test.rswith an infrastructure guard + at least a healthz test. -
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 thelocal-dev.adocport 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:
-
rulesets/{jurisdiction}/— create the directory. -
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. Userulesets/georgia/jurisdiction.tomlas the reference. Every value needs acitations.tomlentry (ADR-011). -
JDM rulesets — at minimum for SNAP:
snap-eligibility.json,snap-deductions.json,snap-disqualification-screening.json. Copy Georgia’s and adjust thresholds/policy. -
Federal rulesets —
rulesets/federal/*.jsonare shared across jurisdictions (FPL, allotments, income limits); no change needed unless federal parameters differ (rare). -
Typst notice templates + components —
rulesets/{jurisdiction}/notices/snap/and…/notices/components/with jurisdiction-specific letterhead, addresses, legal citations, hearing-rights phone numbers. Orchard components are jurisdiction-agnostic. -
Theme —
rulesets/{jurisdiction}/theme.tomlfor BFF sidebar branding. -
Signing keys —
cargo xtask gen-signing-keys --program snapfor the new key pair under.keys/. -
Env vars — set
CANOPY_{SERVICE}__JURISDICTION={jurisdiction}for all services. -
Validation —
cargo 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, …, intemplates/_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 CSPstyle-src 'self'(services/canopy-web/src/csp.rs). All sizing is via discrete class variants;primitives_test.rspins 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 aPlugin.tomlMUST declarerequired_states = ["empty", "loading", "error", "populated"]; the manifest validator rejects anything outside the four-state set. -
Plugin authoring. Declare
Plugin.tomlper the ADR-021 schema; attach#[canopy_plugin(slug="…", manifest="…")]to the handler struct. The macro emits alinkme::distributed_sliceentry thatCompileTimePluginSourcewalks at startup.manifest = "…"MUST be a crate-relative path (the macroinclude_str!`s `concat!(env!("CARGO_MANIFEST_DIR"), "/", manifest)). Calldashboard::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 (
UnknownPluginon 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/compositionJSON API. RFC 7232 preconditions on PUT (If-Matchreplace /If-None-Match:create-only; 428/400 on absent/both); PATCH validatesContent-Type: application/json-patch+json*before body deserialization (415 vs 400); errors use the closed-set{ "error": { "code", "message", "details" } }envelope withsqlx/serde/canopy_mqerrors sanitized (logged server-side, never leaked). The sub-router carries anExtension<Arc<CompositionState>>layer (NOTRouter<CompositionState>) so it merges intoRouter<AppState>. CSRF on these JSON routes relies on the session cookie’sSameSite=Strictattribute (set inmain.rswith a load-bearing comment) — do NOT downgrade toLax/Nonewithout first adding a CSRF-token middleware. JSON extractors (JsonAuthenticatedWorker,JurisdictionAdmin) share the HTML BFF’s refresh-token + fail-closed semantics viaresolve_worker_or_failinservices/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-signedAuditEvent`s per ADR-014. Renders go through `Arc<dyn AuditEmitter>(AmqpAuditEmitter, best-effort fire-and-warn). Mutation audit does NOT — handlers emit inline viapublisher.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:
-
Have you written any code that needs tests? If so, write them.
-
Have you used any hacks or bypasses? If so, undo them and implement correctly.
-
Have you weakened any tests? If so, undo the weakening and fix the broken functionality.
-
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.
-
Have you updated GitLab issues/epics/milestones? If no, do so.
-
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.
-
Does any documentation need updating? If so, do the update.
-
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.