ADR-007: CLI/API/UI Parity
On this page
Status
Accepted — 2026-03-27; Amended — 2026-08-24 (Amendment 1: human-only authorization surfaces, #1417)
Context
Canopy exposes three interaction surfaces: a REST API (consumed by program services and integrations), a worker portal UI (canopy-web), and an applicant portal UI (canopy-portal). Without a CLI, the only way to script operations, automate testing, or perform ad-hoc queries is by hand-crafting HTTP requests with curl and a Keycloak token.
OpenStack demonstrated that CLI/API/UI parity — where every operation available in one surface is available in all three — enables:
-
Scriptable automation — batch operations, data migration, CI/CD integration
-
Developer productivity — faster iteration than navigating a UI for every test
-
Operational parity — on-call staff can investigate and act without a browser
-
Incremental testability — CLI commands become the foundation for integration test scripts
-
Accessibility — screen reader users and keyboard-only operators get full access via the CLI
The OpenStack CLI (openstack) is the model: a thin client over the REST API using clap-style subcommands, profile-based configuration, and table/json output formatting.
Decision
Every operation exposed by any Canopy service’s REST API must also be available as a canopy CLI subcommand. The CLI is a first-class interface, not a convenience wrapper. New API endpoints ship with corresponding CLI commands in the same MR or the immediately following one.
CLI Architecture
-
Binary:
tools/canopy-cli/— a standalone Rust binary usingclapandreqwest -
Library crate:
tools/canopy-cli/src/lib.rsexportsCli,Commands, subcommand types,ApiClient,output, andconfigmodules so integration tests can drive the CLI programmatically -
API client:
ApiClientwrapsreqwest::Clientwith bearer token injection. All CLI commands call the REST API — no direct database access, no bypassing service boundaries -
Profile config:
~/.config/canopy/profiles.tomlstores named profiles with service URLs and Keycloak settings.--profileflag (default:default). Auto-created on first run. -
Auth:
canopy loginacquires a Keycloak token via ROPC grant and stores it in~/.config/canopy/tokens/.canopy token showdisplays the current token. All other commands auto-refresh expired tokens. -
Output:
--format table|json(global flag, defaulttable). Table output usestabledcrate. JSON output is raw API response for piping tojq. -
Shell completions:
canopy completion bash|zsh|fish|powershell
Command Structure
Commands mirror the API structure:
canopy login
canopy token show|refresh
canopy person create|list|get|update|delete
canopy household create|get|add-member|remove-member
canopy person add-income|add-asset|add-expense|add-address
canopy application create|list|get|update|withdraw
canopy application waive-interview|complete-interview
canopy rules list|get|create|update|delete|import|evaluate
canopy eligibility determine|get-determination|list-determinations
canopy snap evaluate
canopy tanf evaluate
canopy medicaid evaluate
canopy caps evaluate
canopy wic evaluate
canopy enrollment list|get
canopy renewal list|get|process
canopy notice list|get
canopy appeal file|list|get|schedule-hearing|record-decision
canopy report generate|list|export
canopy security events|alerts|nist-controls
canopy security chain-status|chain-verify|chain-attest
canopy completion bash|zsh|fish|powershell
The security chain- trio replaced the original security verify-chain
command when the chain surface was unified under /v1/security/chain/
(#1205, ADR-014 Amendment 9;
GET /v1/security/verify-chain is deleted): chain-status --family <audit|fti>
[--service <canopy-tanf|canopy-medicaid>] (exits non-zero on the fail-closed
503 arm), chain-verify --family … [--service …] [--loop <tail|scrub|family-full>]
[--wait] (202 job handle; --wait polls the job to done/error), and
chain-attest --event-id <uuid> --family … [--service …] (exits non-zero when
not attested). Both families are ACTIVE: the fti arguments
(--family fti --service canopy-{tanf,medicaid}) are served live since MR-3
of #1206 — the CLI forwards them unchanged (honest passthrough, per this
ADR’s thin-client rule), and a dormant fti target (until #1279) comes back as
the server’s typed 503, parsed and named by the same fail-closed exit path.
Parity Enforcement
-
Every plan that adds API endpoints must include a "CLI commands" section in its Steps listing the corresponding
canopysubcommands -
The
canopy-cliCargo.toml depends on no internal crates except via the HTTP API — it is a pure REST client -
Integration tests can use the CLI library crate to drive end-to-end scenarios
Consequences
-
Every service plan must account for CLI command additions — this increases scope slightly but ensures no operation is UI-only
-
The CLI binary ships in the Docker image alongside service binaries for operational use
-
The
tools/canopy-cli/directory grows incrementally as services are implemented — it is never "done" until all services are complete -
Shell completion support enables discoverability without documentation
Alternatives Considered
No CLI (rejected)
Rely on curl + API documentation. Rejected because scripting with curl requires managing tokens, constructing JSON payloads, and parsing responses manually — too slow for development and operations.
Amendment 1 (2026-08-24, #1417): human-only authorization surfaces are a documented parity exception
The post-C1 authorization model (ADR-043 §C, #1443) created a surface class parity cannot reach without weakening the fleet’s authorization controls: endpoints whose HUMAN arm accepts only an exchanged user-context bearer minted by an azp-allowlisted exchanger, and whose service arm is refused at an in-handler human projection. The first member is POST /v1/applications/{id}/documents/{document_id}/scan-override (#1006, #1443): the release must name its accountable human, so a bare service token 403s at the releasing_supervisor projection and a direct worker login — exactly what `canopy login’s ROPC grant produces (broad audience) — 403s at the receiver contract.
The canopy CLI holds exactly those two credential shapes, and MUST: making the CLI an authorized exchanger would add a user-context-minting client to every receiver’s authorized_exchanger_azps allowlist, widening the fleet’s most sensitive control for a scripting convenience.
Decision: such endpoints are documented CLI-unsupported, with the worker portal — whose canopy-web-exchanger is the audited, allowlisted minting path — as the sole human path. Members are enumerated here and noted in the CLI help of the nearest sibling command:
-
POST /v1/applications/{id}/documents/{document_id}/scan-override(sibling:canopy application document-rescan).
Revisiting this (e.g. a CLI device-flow client with its own exchanger azp) is a maintainer security decision to be taken deliberately, never a parity default.