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:

  1. Scriptable automation — batch operations, data migration, CI/CD integration

  2. Developer productivity — faster iteration than navigating a UI for every test

  3. Operational parity — on-call staff can investigate and act without a browser

  4. Incremental testability — CLI commands become the foundation for integration test scripts

  5. 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 using clap and reqwest

  • Library crate: tools/canopy-cli/src/lib.rs exports Cli, Commands, subcommand types, ApiClient, output, and config modules so integration tests can drive the CLI programmatically

  • API client: ApiClient wraps reqwest::Client with bearer token injection. All CLI commands call the REST API — no direct database access, no bypassing service boundaries

  • Profile config: ~/.config/canopy/profiles.toml stores named profiles with service URLs and Keycloak settings. --profile flag (default: default). Auto-created on first run.

  • Auth: canopy login acquires a Keycloak token via ROPC grant and stores it in ~/.config/canopy/tokens/. canopy token show displays the current token. All other commands auto-refresh expired tokens.

  • Output: --format table|json (global flag, default table). Table output uses tabled crate. JSON output is raw API response for piping to jq.

  • 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 canopy subcommands

  • The canopy-cli Cargo.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.

GraphQL endpoint (rejected)

A single GraphQL endpoint would provide query flexibility but adds complexity, doesn’t solve the scripting problem (still need a client), and conflicts with the per-service isolation model (ADR-001).

TUI (deferred)

A terminal UI (like k9s for Kubernetes) could provide a richer interactive experience. Deferred — the CLI is the priority; a TUI can wrap the same ApiClient later.

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.

Edit this page · default