feat(standards): CLI for typed payloads, the dictionary and attachments (#49) #62
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.
payload putrenders every problem. One per line on stderr for a human, the whole list in the--jsonenvelope 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.symbol,locatorandexactpass through untouched, both directions. The symbol and locator are the source document’s own — the drawing behind the motivating defect usesEfor body width andE1for lead span, and a reading is only checkable by a second reader when it records them.exact: trueis a basic/BSC dimension: zero tolerance, not a nominal, and flattening it changes the arithmetic in every downstream stack-up.- No content hash is ever sent. The server computes it from the bytes it received. The upload is multipart with one field named
fileand nothing else, asserted against the recorded request body. dictionary importplans before it writes. Every dangling reference and duplicate in one pass (not first-fail); superclasses ordered ahead of their subclasses — the server resolvessuperclassby 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:
importis full-fidelity — the document may declaresuperclass,case_ofanddefinition_class, and all three are applied.exportis not.export | importreproduces 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— cleancargo 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— oktests/json_contract.rsleaf 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.