ref:main

feat(standards): CLI for typed payloads, the dictionary and attachments (#49) #62

open colechristensen cole.christensen@gmail.com wants to merge feat/standards-dictionary-cli into main

Closes #49

fangorn/anvil#405 and #398 landed server-side and touched zero files in this repo. Everything the feature offers was raw HTTP: one request per property and per class, no bulk path, and no way to see a payload rejection except by reading a JSON body by hand. For data whose whole value is careful transcription, the tool used to enter it should not itself be an error source.

What shipped

Command Route
standard attach <STD-…> --file POST /:org/standards/:id/attachments
standard attachments <STD-…> GET /:org/standards/:id (the attachments half)
standard download <att-id> [--output] GET /attachments/:id
standard payload get <STD-…> GET /:org/standards/:id (the payload half)
standard payload put <STD-…> --file PUT /:org/standards/:id/payload
dictionary property list | create GET | POST /:org/dictionary/properties
dictionary class list | show | create GET | GET /:code | POST /:org/dictionary/classes
dictionary class put-property | put-case-of | put-constraint | put-constant the four POST /:org/dictionary/classes/:code/* routes
dictionary export | import client-side bulk over the above

--json on every read, per the existing convention: reads echo the server body bare, mutations are {"ok":true,…}, errors {"ok":false,"error":…}.

standard attachments is one command beyond the ticket’s list. download takes an opaque attachment id, and without a way to list them the id has nowhere to come from — it is a read of an endpoint already being called, so it costs nothing.

The strict gate in the ticket’s Notes already has a CLI surface: anvil requirement status --strict-standards --organization <slug>. Nothing added there.

Four things this had to get right

Each has a test that fails without it — verified by mutation, not by assumption.

  1. payload put renders every problem. One per line on stderr for a human, the whole list in the --json envelope for a script, exit 1. A payload with four transcription errors costs one round trip. Printing only the first would make this worse than curl, which at least shows the body. Changeset-shaped 422s (errors: {field: [msg]}) are flattened the same way; a 404 falls through to the ordinary error envelope rather than being reported as zero problems.
  2. symbol, locator and exact pass through untouched, both directions. The symbol and locator are the source document’s own — the drawing behind the motivating defect uses E for body width and E1 for lead span, and a reading is only checkable by a second reader when it records them. exact: true is a basic/BSC dimension: zero tolerance, not a nominal, and flattening it changes the arithmetic in every downstream stack-up.
  3. No content hash is ever sent. The server computes it from the bytes it received. The upload is multipart with one field named file and nothing else, asserted against the recorded request body.
  4. dictionary import plans before it writes. Every dangling reference and duplicate in one pass (not first-fail); superclasses ordered ahead of their subclasses — the server resolves superclass by code at create time and silently drops one it cannot find, so a file in author order would lose the linkage with no error anywhere; existing codes left alone, since a dictionary code is immutable and there is no update route; and nothing sent at all for a document that does not parse, because a half-applied dictionary is worse than a rejected one. Unknown keys are rejected rather than ignored — a typo that vanishes is a transcription error nobody ever sees.

The no-codegen guard

tests/no_codegen_guard.rs, the CLI-side sibling of no_codegen_guard_test.exs. No command, flag or function may turn a payload or the dictionary into source.

Both halves earn their place, and the mutation run proves it: planting fn generate_types_from_payload fails the source scan only; planting a GenerateTypes clap variant fails the clap walk only, because GenerateTypes lowercases to generatetypes and only the rendered command name (generate-types) reveals it. A third test asserts the marker list still matches an obvious affordance and still ignores prose, so the guard cannot go quietly vacuous.

Known limit: export is lossy where the read API is

Called out in the module docs rather than papered over. GET /classes and GET /classes/:code return a class’s resolved view and do not return superclass, case_of, short_name, synonymous_names, note, remark, a property’s definition_class, or any marker of which declarations are local versus inherited. So:

  • import is full-fidelity — the document may declare superclass, case_of and definition_class, and all three are applied.
  • export is not. export | import reproduces what every class demands, correctly, but flattens the hierarchy: the spine and the imports are gone and each class carries its inherited declarations as its own.

Keep the hand-written file as the source of truth; use export to read a live org, not as its backup. Worth a server-side ticket to add the declared-vs-resolved fields to the class read.

New dependency

serde_norway 0.9 (a maintained fork of the deprecated serde_yaml, same API). Unlike requirement import, which posts the raw text to a server-side importer, the dictionary has no import route — so the bulk path parses YAML client-side and there was no existing YAML parser in the tree.

Verification

CI is the check, but everything was run locally first:

  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean (nothing silenced)
  • the full suite — 761 passed, 0 failed, of which 38 are new (13 end-to-end, 3 guard, 22 unit)
  • cargo build --release — ok
  • tests/json_contract.rs leaf inventory updated with all 16 new leaves

Discrimination, proven by mutation rather than asserted:

mutation fails
render only the first problem payload_put_renders_every_problem
drop value_format from the export body dictionary_export_import_round_trips
import in file order, not superclass order dictionary_import_creates_superclasses_first
plant generate_types_from_payload the_cli_source_contains_no_payload_to_source_affordance
plant a GenerateTypes subcommand the_command_surface_offers_no_generator

Each mutation failed exactly the test that claims to cover it, and no others.

Created Aug 27, 2026 at 07:33 UTC